Build a Custom Hook for Real-Time State Synchronization in React

Peter Mbanugo - Jan 18 '21 - - Dev Community

Hooks are a new addition to React since version 16.8. They give us a simpler way to write stateful logic so that it can be reused and tested separately. It's been a while since its release and you may have used it in your apps. If you've not used it and don't know why you need it, refer to the docs before you continue reading.

Hooks allow you to reuse stateful logic without changing your component hierarchy, making it easier to share this logic across many components. The focus of this post is to show you how to extract reusable stateful logic to a custom hook and use it in any component.

The example will be an editable table and a form where data changes will be propagated to other users of the app in real-time. In the end, you'll have a custom hook working like Redux in real-time, but with a few lines of code and integration of a real-time data synchronization service.

If you just want to see working code, head over to GitHub and grab the code. If you don't want to read the steps to set up the application and only want to see how we extract the logic as a hook function and refactor the components, then skip to the Creating and Using Custom Hooks section.

Application Set-Up

We're going to use create-react-app to bootstrap a new React application and also install react-table. Open your command-line application and run the commands below:

  • npx create-react-app realtime-react-hooks
  • cd realtime-react-hooks && npm i react-table@6

With the project created, we're going to add the components we need. Add a new file components/Header.js and put the code below in it.

import React from "react";
import logo from "../logo.svg";

const Header = () => (
  <header>
    <img src={logo} className="App-logo" alt="logo" />
    <h1 className="App-title">Realtime React Datagrid</h1>
  </header>
);

export default Header;
Enter fullscreen mode Exit fullscreen mode

Add another component components/Form.js and paste the code in the section below in it.

import React from "react";

const Form = () => {
  const [firstName, setFirstName] = React.useState("");
  const [lastName, setLastName] = React.useState("");

  const handleChange = (event) => {
    if (event.target.name === "firstName") setFirstName(event.target.value);
    if (event.target.name === "lastName") setLastName(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
  };

  return (
    <form onSubmit={handleSubmit}>
      <h3>Add new record</h3>
      <label>
        FirstName: 
        <input
          type="text"
          name="firstName"
          value={firstName}
          onChange={handleChange}
        />
      </label>
      &nbsp;&nbsp;
      <label>
        LastName: 
        <input
          type="text"
          name="lastName"
          value={lastName}
          onChange={handleChange}
        />
      </label>
      &nbsp;&nbsp;&nbsp;
      <input type="submit" value="Add" />
    </form>
  );
};

export default Form;
Enter fullscreen mode Exit fullscreen mode

This is a simple HTML form that will be used to collect data that will be added to the table. The handleSubmit function does nothing for now, but we'll modify it in a later section.

You're going to add another component that you will put in a file called components/Table.js. Create that file and paste the code below in it.

import React from "react";
import ReactTable from "react-table";
import "react-table/react-table.css";

const Table = () => {
  const [data, setData] = React.useState([]);

  const renderEditableCell = (data) => (cellInfo) => {
    return (
      <div
        style={{ backgroundColor: "#fafafa" }}
        contentEditable
        suppressContentEditableWarning
        onBlur={(e) => {
          let row = data[cellInfo.index];
          row[cellInfo.column.id] = e.target.innerHTML;
          // TODO: update state with new value
        }}
        dangerouslySetInnerHTML={{
          __html: data[cellInfo.index][cellInfo.column.id],
        }}
      />
    );
  };

  return (
    <ReactTable
      data={data}
      columns={[
        {
          Header: "First Name",
          accessor: "firstName",
          Cell: renderEditableCell(data),
        },
        {
          Header: "Last Name",
          accessor: "lastName",
          Cell: renderEditableCell(data),
        },
        {
          Header: "Full Name",
          id: "full",
          accessor: (d) => (
            <div
              dangerouslySetInnerHTML={{
                __html: d.firstName + " " + d.lastName,
              }}
            />
          ),
        },
      ]}
      defaultPageSize={10}
      className="-striped -highlight"
    />
  );
};

export default Table;
Enter fullscreen mode Exit fullscreen mode

The Table component renders a table with data, and it is editable. We're using react-table library to achieve this but I won't go into details about its API. For this example, we define the table cell headers and what data should be rendered in each cell in the renderEditableCell function.

Using Hooks and Adding Real-time Data

Now that we have the base for the application, we will add a library that will be used to manage real-time data synchronization and use hooks to manage stateful logic. We will be using Hamoni Sync which allows you to store and retrieve data in real-time. It has a JavaScript package that we can install from npm.

Run npm install hamoni-sync to install the package.

In order to use the service, we need to sign up to get an Application ID and Account ID. Follow the steps below to register and retrieve your account and application ID which will be needed later.

  • Register and log in to Hamoni dashboard.
  • Enter your preferred application name in the text field and click the create button. This should create the app and display it in the application list section.
  • Expand the Account ID card to get your account ID.

Hamoni Sync Dashboard

You're going to use the hamoni-sync library to retrieve data and get real-time updates. You’re going to create a context object that’ll be used to pass the instance of hamoni-sync to components that need it. Add a new file called HamoniContext.js with the code below:

import React from "react";

const HamoniContext = React.createContext({});
export default HamoniContext;
Enter fullscreen mode Exit fullscreen mode

Open App.js and update it with the code below.

import React, { useState, useEffect } from "react";
import "./App.css";
import Hamoni from "hamoni-sync";

import Header from "./components/Header";
import Table from "./components/Table";
import Form from "./components/Form";

import HamoniContext from "./HamoniContext";

const App = () => {
  const accountId = "REPLACE_WITH_ACCOUNT_ID";
  const appId = "REPLACE_WITH_APP_ID";
  const [hamoni, setHamoni] = useState();

  useEffect(() => {
    const initialiseHamoniSync = async () => {
      // recommended to generate this from your backend and send to your client apps.
      const response = await fetch("https://api.sync.hamoni.tech/v1/token", {
        method: "POST",
        headers: {
          "Content-Type": "application/json; charset=utf-8",
        },
        body: JSON.stringify({ accountId, appId }),
      });

      const token = await response.json();
      const hamoniSync = new Hamoni(token);
      await hamoniSync.connect();

      setHamoni(hamoniSync);
    };

    initialiseHamoniSync();
  }, [accountId, appId]);

  return (
    <HamoniContext.Provider value={hamoni}>
      <div className="App">
        <Header />
        <Form />
        <br />
        <Table />
      </div>
    </HamoniContext.Provider>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

In the code you just added, we initialize the Hamoni Sync client and pass it to the Form and Table components using the context API. In order to connect to Hamoni Sync server, you need an authentication token. This is generated from a specific API as you can see. Although we put that in the React code, it is recommended to do this on the backend and send the token to your React client. This is to avoid exposing your account and application ID.

For the Form component, we want to store the data collected and display it in the table. We're going to update the component's logic to include logic for this. To do this, import the HamoniContext from App.js and get access to the instance of Hamoni Sync:

import HamoniContext from "../HamoniContext";

const Form = () => {
  ....existing code

  const hamoni = React.useContext(HamoniContext);
  const [syncPrimitive, setSyncPrimitive] = React.useState(null);

  React.useEffect(() => {
    if (hamoni) {
      const getState = async () => {
        try {
          const listPrimitive = await hamoni.get("datagrid");
          setSyncPrimitive(listPrimitive);
        } catch (error) {
          console.log(("Hamoni Sync Error", error));
        }
      };
      getState();
    }
  }, [hamoni]);

  const handleSubmit = (event) => {
    event.preventDefault();

    syncPrimitive.add({
      firstName: firstName,
      lastName: lastName,
    });
    setLastName("");
    setFirstName("");
  };

  ...existing code
}
Enter fullscreen mode Exit fullscreen mode

Hamoni Sync has what is called Sync primitives as a way to store and modify state. There are three kinds of Sync primitives: Value, Object, and List primitives. We’re going to use List primitive because it provides an API for us to store and modify data that needs to be stored in an array-like manner. You can read more about sync primitives from the docs.

Just as you name variables to hold your data, you define names for the container where your data is stored. This is the name that is used to retrieve the data. In our example, we used hamoni.get("datagrid") to retrieve the state object, and datagrid is the name of the state. In the handleSubmit function, we update the state by calling syncPrimitive.add().

We want to disable the Add button until Hamoni Sync is ready. Therefore we will update line 24 to:

<input type="submit" value="Add" disabled={syncPrimitive === null} />
Enter fullscreen mode Exit fullscreen mode

Let's update the Table component to retrieve data from Hamoni Sync. First, import the HamoniContext:

import HamoniContext from "../HamoniContext";
Enter fullscreen mode Exit fullscreen mode

Then add the code below to the component's function starting from line 8.

  const hamoni = React.useContext(HamoniContext);
  const [syncPrimitive, setSyncPrimitive] = React.useState(null);

  React.useEffect(() => {
    if (hamoni) {
      const getState = async () => {
        try {
          const listPrimitive = await hamoni.get("datagrid");
          setSyncPrimitive(listPrimitive);
        } catch (error) {
          console.log(("Hamoni Sync Error: ", error));
        }
      };
      getState();
    }
  }, [hamoni]);

  React.useEffect(() => {
    if (syncPrimitive) {
      setData([...syncPrimitive.getAll()]);

      syncPrimitive.onSync((data) => {
        setData([...data]);
      });

      syncPrimitive.onItemUpdated((item) => {
        setData((previousData) => [
          ...previousData.slice(0, item.index),
          item.value,
          ...previousData.slice(item.index + 1),
        ]);
      });

      syncPrimitive.onItemAdded((item) => {
        setData((previousData) => [...previousData, item.value]);
      });
    }
  }, [syncPrimitive]);
Enter fullscreen mode Exit fullscreen mode

We added two useEffect logics. The first is to get an object that will be used to retrieve data stored in Hamoni Sync, and the second one gets data and updates the React state, then subscribes to receive changes made to the data. If you want to learn more about Hamoni Sync's API, please refer to the docs.

Replace the comment on line 56 in Table.js with the code statement below:

syncPrimitive.update(cellInfo.index, row);
Enter fullscreen mode Exit fullscreen mode

This code is used to update the state in Hamoni Sync, which then gets propagated to connected clients.

Creating and Using Custom Hooks

Now, we have code to connect to Hamoni Sync and work with the data using the JavaScript library. We can add and update data in real-time. You may have noticed that the Form and Table components share a similar logic for retrieving Hamoni Sync's state primitive. We can extract this logic into a custom hook that can be used in those components without duplicating code.

A custom hook is a function whose name starts with ”use” and may call other hook functions. It doesn't need to have any specific argument or return type.

How do we extract a custom hook from our current codebase?

If you look at Table and Form components, we have this exact statement in both of them.

  React.useEffect(() => {
    if (hamoni) {
      const getState = async () => {
        try {
          const listPrimitive = await hamoni.get("datagrid");
          setSyncPrimitive(listPrimitive);
        } catch (error) {
          console.log(("Hamoni Sync Error: ", error));
        }
      };
      getState();
    }
  }, [hamoni]);
Enter fullscreen mode Exit fullscreen mode

We will extract this logic into a separate function which we will call useSyncState and it'll return the listPrimitive object.

Create a new folder named hooks with a file called use-sync.js and paste the code below in it.

import { useState, useContext, useEffect } from "react";
import HamoniContext from "../HamoniContext";

function useSyncState(name) {
  const hamoni = useContext(HamoniContext);
  const [syncPrimitive, setSyncPrimitive] = useState(null);

  useEffect(() => {
    if (hamoni) {
      const getState = async () => {
        try {
          const listPrimitive = await hamoni.get(name);
          setSyncPrimitive(listPrimitive);
        } catch (error) {
          console.log(("Hamoni Sync Error", error));
        }
      };
      getState();
    }
  }, [hamoni, name]);

  return syncPrimitive;
}

export default useSyncState;
Enter fullscreen mode Exit fullscreen mode

There you have it! A custom hook that returns an object that will be used to access state in Hamoni Sync. In order to use it, we'll replace the code on line 2 of Form.js, and line 4 of Table.js with:

import useSyncState from "../hooks/use-sync"
Enter fullscreen mode Exit fullscreen mode

In Form.js & Table.js, replace line 8 to 23 where you have the useContext and useEffect hooks with the code below.

const syncPrimitive = useSyncState("datagrid");
Enter fullscreen mode Exit fullscreen mode

Now you have less code in the component and can reuse the hook’s logic in any component. With the combination of hooks and Hamoni Sync, we have some sort of real-time Redux logic where there's a single source of truth that gets updated in real-time.

We can also extract the code in App.js to use a custom hook. In App.js* we have this code in it:

 const [hamoni, setHamoni] = useState();

useEffect(() => {
  const initialiseHamoniSync = async () => {
    // recommended to generate this from your backend and send it to your client apps.
    const response = await fetch("https://api.sync.hamoni.tech/v1/token", {
      method: "POST",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify({ accountId, appId }),
    });

    const token = await response.json();
    const hamoniSync = new Hamoni(token);
    await hamoniSync.connect();

    setHamoni(hamoniSync);
  };

  initialiseHamoniSync();
}, [accountId, appId]);
Enter fullscreen mode Exit fullscreen mode

We can extract this piece into a separate custom hook. To do that, create new file use-hamoni.js in the hooks folder. Copy the code below and paste in it.

 import { useState, useEffect } from "react";
import Hamoni from "hamoni-sync";

const useHamoni = (accountId, appId) => {
  const [hamoni, setHamoni] = useState();

  useEffect(() => {
    const initialiseHamoniSync = async () => {
      // recommended to generate this from your backend and send it to your client apps.
      const response = await fetch("https://api.sync.hamoni.tech/v1/token", {
        method: "POST",
        headers: {
          "Content-Type": "application/json; charset=utf-8",
        },
        body: JSON.stringify({ accountId, appId }),
      });

      const token = await response.json();
      const hamoniSync = new Hamoni(token);
      await hamoniSync.connect();

      setHamoni(hamoniSync);
    };

    initialiseHamoniSync();
  }, [accountId, appId]);

  return hamoni;
};

export default useHamoni;
Enter fullscreen mode Exit fullscreen mode

We can then use this import and use this hook in App.js. In App.js, remove the import statement for hamoni-sync and after line 8 add the import statement to the useHamoni hook.

import useHamoni from "./hooks/use-hamoni";
Enter fullscreen mode Exit fullscreen mode

Then replace the useState and useEffect statements with the code statement below.

const hamoni = useHamoni(accountId, appId);
Enter fullscreen mode Exit fullscreen mode

Now your App.js file should have the same content as the code below.

import React from "react";
import "./App.css";

import Header from "./components/Header";
import Table from "./components/Table";
import Form from "./components/Form";

import HamoniContext from "./HamoniContext";
import useHamoni from "./hooks/use-hamoni";

const App = () => {
  const accountId = "REPLACE_WITH_ACCOUNT_ID";
  const appId = "REPLACE_WITH_APP_ID";
  const hamoni = useHamoni(accountId, appId);

  return (
    <HamoniContext.Provider value={hamoni}>
      <div className="App">
        <Header />
        <Form />
        <br />
        <Table />
      </div>
    </HamoniContext.Provider>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Now you have two custom hooks in the application and you can use this strategy in extracting logic as custom hook functions in your application.

In order to run the application and get data without errors, we need to create/initialize the state in Hamoni Sync. This is according to Hamoni Sync's design. For this reason, you will add a script that you can run once to set up the service with data. Add a new file seed.js and paste the code below in it:

const Hamoni = require("hamoni-sync");
const hamoni = new Hamoni("AccountID", "APP_ID");

hamoni
  .connect()
  .then(response => {
    hamoni
      .createList("datagrid", [
        { firstName: "James", lastName: "Darwin" },
        { firstName: "Jimmy", lastName: "August" }
      ])
      .then(() => console.log("create success"))
      .catch(error => console.log(error));
  })
  .catch(error => console.log(error));
Enter fullscreen mode Exit fullscreen mode

Replace the AccountID and APP_ID placeholders with your account details. Then run this script from the command line by running the command node seed.js. After this is done you can run the React app with npm start and try out the application.

Custom React Hooks

You can find source code for this example on GitHub.

Originally posted on Telerik Blog

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .