Enter JSON-Server
JSON-Server is dope. It lets us simulate a REST API. We can make requests to any one of the HTTP verbs (GET, PUT, POST, DELETE) and our mock data is persisted in a JSON file in our project directory. You can even use query strings in your endpoints for pagination, sorting, and filtering, very much like a real API. With JSON-Server we can quickly prototype front-end server calls using Fetch, Axios, React-Query, etc.
Install JSON-Server globally:
npm -g json-server
Create a db.json
file in the root directory of the project, and populate it with a few records. Create an object with a "todos" property that will contain an array of todo objects.
{ "todos": [ { "title": "Gym", "complete": false, "id": 1 }, { "title": "Tan", "complete": false, "id": 2 }, { "title": "Laundry", "complete": false, "id": 3 } ]}
In a separate terminal, start JSON-Server from the root. The --watch
flag watches the file for changes and updates the routes/data accordingly. The --port
command specifies the port you want JSON-Server to serve up our API, which it automagically creates routes for our resources.
json-server --watch db.json --port 4000
Loading db.jsonDone
Resourceshttp://localhost:4000/todos
Homehttp://localhost:4000
Type s + enter at any time to create a snapshot of the databaseWatching...
We're going to walk through creating the functions for making REST calls to GET, POST, PUT and DELETE using the Fetch API and JSON-Server. Not going to go into too much detail about the todo app itself but you can check out the repo to see the full code.
import { useState, useEffect } from 'react';import Form from './components/Form';import Todo from './components/Todo';
function App() { const [todos, setTodos] = useState([]); const url = 'http://localhost:4000/todos';
useEffect(() => { (() => { fetch(url) .then(res => res.json()) .then( data => setTodos(data), error => console.log(error.message) ); })(); }, []);
// ...
return ( <div> <h1>Todo List Fetch</h1> <Form addTodo={addTodo} /> {todos.map(todo => ( <Todo key={todo.id} todo={todo} updateTodo={updateTodo} deleteTodo={deleteTodo} /> ))} </div> );}
export default App;
We all useEffect()
right? It runs once on mount, unless it detects a change in component state, at which it will run again. That state must be passed to the dependency array, which is useEffect's second argument. If the array is empty, it runs once and only once.
Inside useEffect()
we have an IIFE, which performs a GET request on our endpoint with fetch()
. When you don't pass a method option to fetch()
it defaults to a GET request.
Fetch returns a Promise, thus it is then-able. then()
executes if the Promise resolves. The first then()
where we call json()
on our result, or res, is a necessary step which turns our JSON data into a Javascript object. json()
also returns a Promise, so we can chain another then()
on to our first one. In the second then()
we take the data from our resolved Promise and set the state with it.
function App() { // ...
const addTodo = todo => { fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body: JSON.stringify(todo), }) .then(res => res.json()) .then( data => setTodos(prevState => [...prevState, data]), error => console.log(error.message) ); };
// ...}
The addTodo()
function that takes a todo as an argument. After our URL, the second parameter in fetch()
is the request object. Instead of calling GET we will make a POST request to the server. REST dictates that for POST requests we use the resource's top-level, plural route. We need to pass headers to tell the server we are sending JSON. Any data we pass to the server needs to be sent in the request body.
React strongly emphasizes immutability. setState()
conveniently comes with a previous state argument in it's callback to help us achieve this. If our POST request is successful we are returned a copy of the new object. We set the state to be an array that includes a copy of our previous state (using the spread syntax) and the new object.
import { useState } from 'react';
const Form = ({ addTodo }) => { const [title, setTitle] = useState('');
const handleSubmit = e => { e.preventDefault();
const todo = { title, complete: false, };
addTodo(todo); setTitle(''); };
return ( <form onSubmit={handleSubmit}> <input type="text" value={title} onChange={e => setTitle(e.target.value)} required /> <input type="submit" value="add todo" style={{ marginLeft: '5px' }} /> </form> );};
export default Form;
Pass the addTodo()
function to the handleSubmit()
function on the form that is responsible for creating a todo object.
function App() { // ...
const updateTodo = todo => { fetch(url + todo.id, { method: 'PUT', headers: { 'Content-type': 'application/json' }, body: JSON.stringify(todo), }) .then(res => res.json()) .then( data => setTodos(todos.map(t => (t.id === todo.id ? data : t))), error => console.log(error.message) ); };
// ...}
Now that we can successfully add todos to our "database", let's add the ability to update. Our updateTodo()
function is similar to our addTodo()
function. The main differences are that we are making a PUT request, and concatenating the todo ID to the URL. We are still passing headers, and we are still passing our data to the request body.
While our example is getting the ID from the todo we are passing as an argument, the ID is often taken from URL parameters (example: http://localhost:3000/todos/:id). We aren't using any client side router like React-Router so it's not possible with our example. Retrieving, updating, and deleting a single resource all share the same singular route with the same ID parameter, by REST standards.
How we set the state when our Fetch call resolves is a little different. We map through our todos and if an ID matches the ID of the todo we updated, we replace that todo with the updated one (which we get back if the Promise resolves), and if not, we move on. Array.map()
returns a new array, because immutability right?
function App() { // ...
const deleteTodo = id => { fetch(url + id, { method: 'DELETE' }).then( setTodos(todos.filter(t => t.id !== id)), error => console.log(error.message) ); };
// ...
return ( <div> <h1>Todo List Fetch</h1> <Form addTodo={addTodo} /> {todos.map(todo => ( <Todo key={todo.id} todo={todo} updateTodo={updateTodo} deleteTodo={deleteTodo} /> ))} </div> );}
We need the ability to delete a todo, which is different from marking a todo as complete or incomplete. Deleting removes the record from the "database" entirely. Create a deleteTodo()
function in App.js
. deleteTodo()
takes an ID as it's parameter. That ID is concatenated to our URL inside fetch()
which makes a DELETE request to the server.
This might all seem like magic because, well, we aren't using a real server. A real back end server would have us query the database for the record with the ID that matches the ID in our URL parameters, or send an ID in the request body, then run a delete method on the matching record. This is JSON-Server after all which is only meant to simulate a REST API server that responds with JSON.
If the fetch resolves then we update our todos state. We use Array.filter()
to create a new array of todos whose IDs don't match the ID we passed into the function. Why? Because, you guessed it, immutability!
Our app is basically complete - we can list, add, update, and delete todos from a "server". We have achieved full CRUD and have prototyped a front-end that is near copy-paste if we were to use it with real REST API server. Now all that's left is to code the real server π .
If you've managed to follow along and recreate the app step-by-step, then kudos. I hope I've explained things eloquently enough for that to be true. If you've had some missteps along the way, here is the completed code.