State variables are one of the most important building blocks in front end applications developed with the React library. When a state variable is updated, React automatically re-renders any application components that depend on it, ensuring that the application always displays updated information to the user.
The automatic state update mechanisms in React are fantastic, but they are limited to a single instance of the application. Sometimes, however, an application needs state variables that are automatically synchronized across all running instances of the application. In this tutorial you are going to learn how to create a custom React hook that implements synchronized state variables using Twilio Sync as a cloud storage back end.
Requirements
To work on this tutorial you will need the following items:
- A Twilio account. If you are new to Twilio click here to create a free account now and receive $10 credit when you upgrade to a paid account. You can review the features and limitations of a free Twilio account.
- Node.js installed on your computer. You can download a Node.js installer from the Node.js website.
Create a new React application
To learn how to implement synchronized state variables, we are first going to create a test application.
Create a starter React application using Create React App as follows:
npx create-react-app twilio-sync-state
cd twilio-sync-state
Start the development web server for the application by running this command:
npm start
After a few seconds a new tab in your browser will show the React starter application:
The application you’re going to build as you follow along with this tutorial will have a form that accepts a name. The currently set name will be displayed below the form. The Name
component below implements this functionality. Add this code in a file named src/Name.js.
import { useState } from 'react';
export default function Name() {
const [name, setName] = useState();
const onSubmit = ev => {
ev.preventDefault();
setName(ev.target.name.value);
ev.target.name.value = '';
};
return (
<>
<form onSubmit={onSubmit}>
Your name:
<br />
<input type="text" name="name" size="10" />
<input type="submit" value="Update" />
</form>
<p> Your name is: <b>{name}</b></p>
</>
);
}
To include the Name
component in the application, replace the contents of file src/App.js with the following code:
import Name from './Name';
export default function App() {
return (
<div className="App">
<Name />
</div>
);
}
To test the application out, open two or more instances of the application by navigating to http://localhost:3000. Type random names in each of them and observe how the name
state variable defined in the Name
component is independently managed by React in each application instance.
In the next few sections you are going to create a useSyncState()
hook that mostly works like useState
from React, but adds transparent synchronization across all running instances of the application.
Add a Twilio Sync back end
The first step is to create a small back end server that can generate access tokens for clients to connect to the Twilio Sync service. Open a new terminal window, find a parent directory outside of the React project and run the following command to create an Express server.
npx express-generator -–no-view sync-tokens
cd sync-tokens
npm install
Then add the Twilio helper library for Node.js and a few other required dependencies to the project.
npm install twilio dotenv cors
Next implement a route that returns Twilio Access Tokens in a new file called routes/tokens.js.
const express = require('express');
const twilio = require('twilio');
const router = express.Router();
router.post('/', async (req, res, next) => {
const accessToken = new twilio.jwt.AccessToken(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_API_KEY_SID,
process.env.TWILIO_API_KEY_SECRET,
);
accessToken.identity = Math.random().toString(36).substring(2);
grant = new twilio.jwt.AccessToken.SyncGrant({
serviceSid: process.env.TWILIO_SYNC_SERVICE_SID
});
accessToken.addGrant(grant);
res.send({
token: accessToken.toJwt()
});
});
module.exports = router;
Note that this route returns an access token to any client that requests one. In a real world application this endpoint will be accessible only to authenticated users. For an application that has access to user details, the accessToken.identity
attribute can be used to store a user identifier or name.
Add the following imports in the app.js file found in the top-level directory of the Express project:
require('dotenv').config()
const cors = require('cors');
In the section where routers are initialized, add the “tokens” router:
const tokensRouter = require('./routes/tokens');
Right after the application instance is created, enable cross-origin requests (CORS) with the following line:
app.use(cors());
Finally, in the section where routers are registered with the application, add the tokens router:
app.use('/tokens', tokensRouter);
In case you are having trouble making the above changes, below you can find the complete app.js file with the changes highlighted, but note that due to changes in the Express project you may not have exactly the same code.
require('dotenv').config()
const express = require('express');
const cors = require('cors');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const indexRouter = require('./routes/index');
const tokensRouter = require('./routes/tokens');
const app = express();
app.use(cors());
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter);
app.use('/tokens', tokensRouter);
module.exports = app;
The dotenv
package was added to the project, to import configuration variables from a .env file. Create this file in the root directory of the Express project, and add the following variables to it:
TWILIO_ACCOUNT_SID=
TWILIO_API_KEY_SID=
TWILIO_API_KEY_SECRET=
TWILIO_SYNC_SERVICE_SID=
Now you need to obtain the values for these variables that apply to your Twilio account.
You can obtain the “Account SID” value for your account in the “Account Info” box in the main dashboard of the Twilio Console. Use the “Copy” button to transfer it to the .env file via the clipboard.
For the API key values, open the “Account” dropdown and select “API keys & tokens”. Then click the “Create API Key” button and provide a friendly name for your API key. Once the key is created, you will have access to the “API Key SID” and “API Key SECRET” values that you can paste in the .env file.
For the last configuration variable, click on “Explore Products” and find “Sync”. Once in the Sync dashboard, click on “View Sync Services”. You should expect to see a “Default Service” and next to it its “SID” value. You can use that one, or if you prefer, create a new Sync service specifically for this project. Either way, paste the SID of your Sync service in the last .env variable.
The back end is now complete. If you are working on a UNIX or Mac computer, start the back end as follows:
PORT=3001 npm start
If you are using Microsoft Windows, run the back end with these commands:
set PORT=3001
npm start
The commands above start the back end on port 3001 in your computer. Leave it running for the rest of the tutorial. The React application will be making requests to it soon.
Twilio Sync integration with React
Go back to the terminal in which you worked on the React application. Here run the following command to install the Twilio Sync client library:
npm install twilio-sync
Copy the following code in a file named src/SyncProvider.js. This code provides the base Twilio Sync integration with your React application.
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
import { Client } from 'twilio-sync';
const SyncContext = createContext();
export default function SyncProvider({ tokenFunc, children }) {
const [syncClient, setSyncClient] = useState();
useEffect(() => {
(async () => {
if (!syncClient) {
const token = await tokenFunc();
const client = new Client(token);
client.on('tokenAboutToExpire', async () => {
const token = await tokenFunc();
client.updateToken(token);
});
setSyncClient(client);
}
})();
return () => {
if (syncClient) {
syncClient.shutdown();
setSyncClient(undefined);
}
};
}, [syncClient, tokenFunc]);
return (
<SyncContext.Provider value={syncClient}>
{children}
</SyncContext.Provider>
);
};
With this code a new SyncProvider
component is available to your application. This is a wrapper component that provides access to Twilio Sync to all of its children components. The implementation is somewhat tricky, with most of the complexity dedicated to keeping the Twilio Sync client instance authenticated by calling the tokenFunc
function provided by the caller to obtain access tokens.
The following listing has the implementation of useSyncState()
, a custom hook that uses SyncProvider
and is similar to React’s useState()
. Add this code at the bottom of src/SyncProvider.js.
export function useSyncState(name, initialValue) {
const sync = useContext(SyncContext);
const [doc, setDoc] = useState();
const [data, setDataInternal] = useState();
useEffect(() => {
setDoc(undefined);
setDataInternal(undefined);
}, [sync]);
useEffect(() => {
(async () => {
if (sync && !doc) {
const newDoc = await sync.document(name);
if (!newDoc.data) {
await newDoc.set({state: initialValue});
}
setDoc(newDoc);
setDataInternal(newDoc.data.state);
newDoc.on('updated', args => setDataInternal(args.data.state));
}
})();
return () => { doc && doc.close() };
}, [sync, doc, name, initialValue]);
const setData = useCallback(value => {
(async () => {
if (typeof value === 'function') {
await doc.set({state: value(data)});
}
else {
await doc.set({state: value});
}
})();
}, [doc, data]);
return [data, setData];
}
Unlike React’s useState()
, this useSyncState()
hook function has a required name
first argument, which is used to link all references to a state variable together, across all running instances of the application.
You will modify the Name
component a little later, but for now take a look at how the name
state variable can be defined using the custom hook:
const [name, setName] = useSyncState('name');
The hook also supports providing an initial value for the state variable, which will only be used when the state variable has never been used in Twilio Sync before:
const [name, setName] = useSyncState('name', 'John Doe');
Synchronized state example
What remains is to update the example React application shown earlier in this article to use synchronized state.
The only change required in the Name
component is to import the new hook, and use it instead of useState()
to define the state variable. Here is the updated code for this component, in file src/Name.js.
import { useSyncState } from './SyncProvider';
export default function Name() {
const [name, setName] = useSyncState('name');
const onSubmit = ev => {
ev.preventDefault();
setName(ev.target.name.value);
ev.target.name.value = '';
};
return (
<>
<form onSubmit={onSubmit}>
Your name:
<br />
<input type="text" name="name" size="10" />
<input type="submit" value="Update" />
</form>
<p> Your name is: <b>{name}</b></p>
</>
);
}
The App
component needs to be updated to make the Name
component a child of SyncProvider
. It also needs to provide a function that makes requests to the Express back end and returns valid tokens for the Sync service.
import SyncProvider from './SyncProvider';
import Name from './Name';
export default function App() {
const getToken = async () => {
const response = await fetch('http://localhost:3001/tokens', {
method: 'POST',
});
const data = await response.json();
return data.token;
};
return (
<div className="App">
<SyncProvider tokenFunc={getToken}>
<Name />
</SyncProvider>
</div>
);
}
And with these changes, the state variable automatically updates in all instances of the application.
Conclusion
I hope you’ve found this implementation of synchronized state variables useful and can take advantage of it in your own projects.
If you would like to study this implementation in detail, you may want to access the following documentation links:
I can’t wait to see what you build!
Miguel Grinberg is a Principal Software Engineer for Technical Content at Twilio. Reach out to him at mgrinberg [at] twilio [dot] com if you have a cool project you’d like to share on this blog!