Synchronized State Variables for React using Twilio Sync

April 26, 2022
Written by
Reviewed by

Synchronized State Variables for React using Twilio Sync

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.

Tutorial demonstration

Requirements

To work on this tutorial you will need the following items:

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:

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" />
        &nbsp;
        <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.

React state variable demonstration

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" />
        &nbsp;
        <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.

Synchronized state variable demonstration

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!