Components & Servers
Introduction
In the last chapter, we used a methodology to construct a React app. State management of timers takes place in the top-level component TimersDashboard
. As in all React apps, data flows from the top down through the component tree to leaf components. Leaf components communicate events to state managers by calling prop-functions.
At the moment, TimersDashboard
has a hard-coded initial state. Any mutations to the state will only live as long as the browser window is open. That's because all state changes are happening in-memory inside of React. We need our React app to communicate with a server. The server will be in charge of persisting the data. In this app, data persistence happens inside of a file, data.json
.
EditableTimer
and ToggleableTimerForm
also have hard-coded initial state. But because this state is just whether or not their forms are open, we don't need to communicate these state changes to the server. We're OK with the forms starting off closed every time the app boots.
Preparation
To help you get familiar with the API for this project and working with APIs in general, we have a short section where we make requests to the API outside of React.
curl
We'll use a tool called curl to make more involved requests from the command line.
OS X users should already have curl installed.
Windows users can download and install curl here: https://curl.haxx.se/download.html.
server.js
Included in the root of your project folder is a file called server.js
. This is a Node.js server specifically designed for our time-tracking app.
You don't have to know anything about Node.js or about servers in general to work with the server we've supplied. We'll provide the guidance that you need.
server.js
uses the file data.json
as its "store." The server will read and write to this file to persist data. You can take a look at that file to see the initial state of the store that we've provided.
server.js
will return the contents of data.json
when asked for all items. When notified, the server will reflect any updates, deletes, or timer stops and starts in data.json
. This is how data will be persisted even if the browser is reloaded or closed.
Before we start working with the server, let's briefly cover its API. Again, don't be concerned if this outline is a bit perplexing. It will hopefully become clearer as we start writing some code.
The Server API
Our ultimate goal in this chapter is to replicate state changes on the server. We're not going to move all state management exclusively to the server. Instead, the server will maintain its state (in data.json
) and React will maintain its state (in this case, within this.state
in TimersDashboard
). We'll demonstrate later why keeping state in both places is desirable.
If we perform an operation on the React ("client") state that we want to be persisted, then we also need to notify the server of that state change. This will keep the two states in sync. We'll consider these our "write" operations. The write operations we want to send to the server are:
- A timer is created
- A timer is updated
- A timer is deleted
- A timer is started
- A timer is stopped
We'll have just one read operation: requesting all of the timers from the server.
HTTP APIs
This section assumes a little familiarity with HTTP APIs. If you're not familiar with HTTP APIs, you may want to read up on them at some point.
However, don't be deterred from continuing with this chapter for the time being. Essentially what we're doing is making a "call" from our browser out to a local server and conforming to a specified format.
text/html
endpoint
GET /
This entire time, server.js
has actually been responsible for serving the app. When your browser requests localhost:3000/
, the server returns the file index.html
. index.html
loads in all of our JavaScript/React code.
Note that React never makes a request to the server at this path. This is just used by the browser to load the app. React only communicates with the JSON endpoints.
JSON endpoints
data.json
is a JSON document. As touched on in the last chapter, JSON is a format for storing human-readable data objects. We can serialize JavaScript objects into JSON. This enables JavaScript objects to be stored in text files and transported over the network.
data.json
contains an array of objects. While not strictly JavaScript, the data in this array can be readily loaded into JavaScript.
In server.js
, we see lines like this:
fs.readFile(DATA_FILE, function(err, data) {
const timers = JSON.parse(data);
// ...
});
data
is a string, the JSON. JSON.parse()
converts this string into an actual JavaScript array of objects.
GET /api/timers
Returns a list of all timers.
POST /api/timers
Accepts a JSON body with title
, project
, and id
attributes. Will insert a new timer object into its store.
POST /api/timers/start
Accepts a JSON body with the attribute id
and start
(a timestamp). Hunts through its store and finds the timer with the matching id
. Sets its runningSince
to start
.
POST /api/timers/stop
Accepts a JSON body with the attribute id
and stop
(a timestamp). Hunts through its store and finds the timer with the matching id
. Updates elapsed
according to how long the timer has been running (stop - runningSince
). Sets runningSince
to null
.
PUT /api/timers
Accepts a JSON body with the attributes id
and title
and/or project
. Hunts through its store and finds the timer with the matching id
. Updates title
and/or project
to new attributes.
DELETE /api/timers
Accepts a JSON body with the attribute id
. Hunts through its store and deletes the timer with the matching id
.
Playing with the API
If your server is not booted, make sure to boot it:
npm start
You can visit the endpoint /api/timers
endpoint in your browser and see the JSON response (localhost:3000/api/timers
). When you visit a new URL in your browser, your browser makes a GET request. So our browser calls GET /api/timers
and the server returns all of the timers:
Note that the server stripped all of the extraneous whitespace in data.json
, including newlines, to keep the payload as small as possible. Those only exist in data.json
to make it human-readable.
We can use a Chrome extension like JSONView to "humanize" the raw JSON. JSONView takes these raw JSON chunks and adds back in the whitespace for readability:
We can only easily use the browser to make GET requests. For writing data — like starting and stopping timers — we'll have to make POST, PUT, or DELETE requests. We'll use curl to play around with writing data.
Run the following command from the command line:
$ curl -X GET localhost:3000/api/timers
The -X
flag specifies which HTTP method to use. It should return a response that looks a bit like this:
[{"title":"Mow the lawn","project":"House Chores","elapsed":5456099,"id":"0a4a79cb-b06d-4cb1-883d-549a1e3b66d7"},{"title":"Clear paper jam","project":"Office Chores","elapsed":1273998,"id":"a73c1d19-f32d-4aff-b470-cea4e792406a"},{"title":"Ponder origins of universe","project":"Life Chores","id":"2c43306e-5b44-4ff8-8753-33c35adbd06f","elapsed":11750,"runningSince":"1456225941911"}]
You can start one of the timers by issuing a PUT request to the /api/timers/start
endpoint. We need to send along the id of one of the timers and a start timestamp:
$ curl -X POST \
-H 'Content-Type: application/json' \
-d '{"start":1456468632194,"id":"a73c1d19-f32d-4aff-b470-cea4e792406a"}' \
localhost:3000/api/timers/start
The -H
flag sets a header for our HTTP request, Content-Type
. We're informing the server that the body of the request is JSON.
The -d
flag sets the body of our request. Inside of single-quotes ''
is the JSON data.
When you press enter, curl will quickly return without any output. The server doesn't return anything on success for this endpoint. If you open up data.json
, you will see that the timer you specified now has a runningSince
property, set to the value we specified as start
in our request.
If you'd like, you can play around with the other endpoints to get a feel for how they work. Just be sure to set the appropriate method with -X
and to pass along the JSON Content-Type
for the write endpoints.
We've written a small library, client
, to aid you in interfacing with the API in JavaScript.
Note that the backslash
\
above is only used to break the command out over multiple lines for readability. This only works on macOS and Linux. Windows users can just type it out as one long string.Tool tip: jq
macOS and Linux users: If you want to parse and process JSON on the command line, we highly recommend the tool "jq."
You can pipe curl responses directly into jq to have the response pretty-formatted:
curl -X GET localhost:3000/api/timers | jq '.'
You can also do some powerful manipulation of JSON, like iterating over all objects in the response and returning a particular field. In this example, we extract just the
id
property of every object in an array:
curl -X GET localhost:3000/api/timers | jq '.[] | { id }'
You can download jq here: https://stedolan.github.io/jq/.
Loading state from the server
Right now, we set initial state in TimersDashboard
by hardcoding a JavaScript object, an array of timers. Let's modify this function to load data from the server instead.
We've written the client library that your React app will use to interact with the server, client
. The library is defined in public/js/client.js
. We'll use it first and then take a look at how it works in the next section.
The GET /api/timers
endpoint provides a list of all timers, as represented in data.json
. We can use client.getTimers()
to call this endpoint from our React app. We'll do this to "hydrate" the state kept by TimersDashboard
.
When we call client.getTimers()
, the network request is made asynchronously. The function call itself is not going to return anything useful:
// Wrong
// `getTimers()` does not return the list of timers
const timers = client.getTimers();
Instead, we can pass getTimers()
a success function. getTimers()
will invoke that function after it hears back from the server if the server successfully returned a result. getTimers()
will invoke the function with a single argument, the list of timers returned by the server:
// Passing `getTimers()` a success function
client.getTimers((serverTimers) => (
// do something with the array of timers, `serverTimers`
));
client.getTimers()
uses the Fetch API, which we cover in the next section. For our purposes, the important thing to know is that whengetTimers()
is invoked, it fires off the request to the server and then returns control flow immediately. The execution of our program does not wait for the server's response. This is whygetTimers()
is called an asynchronous function.The success function we pass to
getTimers()
is called a callback. We're saying: "When you finally hear back from the server, if it's a successful response, invoke this function." This asynchronous paradigm ensures that execution of our JavaScript is not blocked by I/O.
We'll initialize our component's state with the timers
property set to a blank array. This will allow all components to mount and perform their initial render. Then, we can populate the app by making a request to the server and setting the state:
class TimersDashboard extends React.Component {
state = {
// leanpub-start-insert
timers: [],
// leanpub-end-insert
};
componentDidMount() {
this.loadTimersFromServer();
setInterval(this.loadTimersFromServer, 5000);
}
loadTimersFromServer = () => {
client.getTimers((serverTimers) => (
this.setState({ timers: serverTimers })
)
);
};
// leanpub-end-insert
// ...
A timeline is the best medium for illustrating what happens:
-
Before initial render
React initializes the component.
state
is set to an object with the propertytimers
, a blank array, is returned. -
The initial render
React then calls
render()
onTimersDashboard
. In order for the render to complete,EditableTimerList
andToggleableTimerForm
— its two children — must be rendered. -
Children are rendered
EditableTimerList
has its render method called. Because it was passed a blank data array, it simply produces the following HTML output:
<div id='timers'> </div>
ToggleableTimerForm
renders its HTML, which is the "+" button. -
Initial render is finished
With its children rendered, the initial render of
TimersDashboard
is finished and the HTML is written to the DOM. -
componentDidMount
is invokedNow that the component is mounted,
componentDidMount()
is called onTimersDashboard
.This method calls
loadTimersFromServer()
. In turn, that function callsclient.getTimers()
. That will make the HTTP request to our server, requesting the list of timers. Whenclient
hears back, it invokes our success function.On invocation, the success function is passed one argument,
serverTimers
. This is the array of timers returned by the server. We then callsetState()
, which will trigger a new render. The new render populates our app withEditableTimer
children and all of their children. The app is fully loaded and at an imperceptibly fast speed for the end user.
We also do one other interesting thing in componentDidMount
. We use setInterval()
to ensure loadTimersFromServer()
is called every 5 seconds. While we will be doing our best to mirror state changes between client and server, this hard-refresh of state from the server will ensure our client will always be correct should it shift from the server.
The server is considered the master holder of state. Our client is a mere replica. This becomes incredibly powerful in a multi-instance scenario. If you have two instances of your app running — in two different tabs or two different computers — changes in one will be pushed to the other within five seconds.