DEV Community

Cover image for Multi-User Todo App Using SyncState
Sanket Sahu
Sanket Sahu

Posted on

Multi-User Todo App Using SyncState

Hi! This article is divided into two parts. Firstly, we will build a Todo app using React and SyncState. Next, we will add a multi-user (shared state) functionality with the help of the remote-plugin of SyncState that works with socket.io.

If you don't already know, SyncState is a general-purpose state-management library for React & JavaScript apps.It can be used for local states and also makes it easy to sync the state across multiple sessions without learning any new APIs.You can read more about it in our official documentation.

You can check out the complete project on Github.

Part 1: Building Todo app with SyncState

Starting the project using Create React App

Let's initialise a basic React app. Make sure that you have node and npm pre-installed.

npx create react-app sync-multi-user-todo
Enter fullscreen mode Exit fullscreen mode

Next, navigate to the project directory:

cd sync-multi-user-todo
Enter fullscreen mode Exit fullscreen mode

Run the project:

npm run start
Enter fullscreen mode Exit fullscreen mode

Navigate to localhost:3000 in your browser.

Your application has now been set-up and you can move on to building the rest of the app.

Styling Your Application

We will use Bootstrap and FontAwesome for nice user interface.

In order to use them, put this in the head section of your public/index.html file:

 <link
   rel="stylesheet"
   href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
 />
 <link
   rel="stylesheet"
   href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css"
   integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2"
   crossorigin="anonymous"
 />
Enter fullscreen mode Exit fullscreen mode

Open App.css and replace the contents with the following:

body {
  background-color: #3f937b;
}
.App {
  text-align: center;
}

.checkbox {
  margin-left: -25px;
}
.caption {
  width: 300px;
}
.addText { 
  width: 350px;
}
button {
  background-color: #61b9a0 !important;
  color: white !important;
}
.btn:focus,
.btn:active {
  outline: none !important;
  box-shadow: none;
  border-color: #61b9a0 !important;
}
.input-todo:focus,
.input-todo:active {
  outline: none !important;
  box-shadow: none !important;
  border-color: #61b9a0;
}

@media (max-width: 375px) {
  .checkbox {
    margin-left: -35px;
  }
  .caption {
    width: 280px;
  }

  .addText {
    width: 330px;
  }
}

@media (max-width: 320px) {
  .caption {
    width: 180px;
  }

  .addText {
    width: 230px;
  }
}

todoTitle {
  width: "89%";
}
Enter fullscreen mode Exit fullscreen mode

Installing SyncState

Now, let's add syncstate to our react application. Open the terminal and execute the following commands:

npm install @syncstate/core
npm install @syncstate/react
Enter fullscreen mode Exit fullscreen mode

SyncState provides createDocStore and Provider

import { createDocStore } from "@syncstate/core";
import { Provider } from "@syncstate/react";
Enter fullscreen mode Exit fullscreen mode

Import createDocStore and Provider in your index.js file.

Creating the store

SyncState maintains a universal store for your application. In this store, all your data is contained in a single document. SyncState uses JSON patches to update the document.

Since we are building a multi-user todo app, we'll need an empty array as our state.

In your index.js file create a store as follows:

const store = createDocStore({ todos: [] });
Enter fullscreen mode Exit fullscreen mode

Wrap your app with Provider and pass the store prop:

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

Replace the content of your App.js file with the following code:

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

function App() {

  return (
    <div className="container mt-5">
      <h2 className="text-center text-white">
        Multi User Todo Using SyncState
      </h2>
      <div className="row justify-content-center mt-5">
        <div className="col-md-8">
          <div className="card-hover-shadow-2x mb-3 card">
            <div className="card-header-tab card-header">
              <div className="card-header-title font-size-lg text-capitalize font-weight-normal">
                <i className="fa fa-tasks"></i>&nbsp;Task Lists
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Let's begin with the CRUD part of our application.

Reading To-Do Items

You can access the store in your components using the useDoc hook:

import { useDoc } from "@syncstate/react";
Enter fullscreen mode Exit fullscreen mode

useDoc hook accepts a path parameter, it returns the state at the path and the function to modify that state. This also adds a listener to the path and updates the component when the state at the path changes.

SyncState uses a pull-based strategy i.e. only those components listen to changes if they are using the data that gets changed in your store.

To get the hold of our todo array, we'll pass the path "/todos" to the useDoc hook:

 const todoPath = "/todos";
 const [todos, setTodos] = useDoc(todoPath);
Enter fullscreen mode Exit fullscreen mode

and to get the hold of a todo item we'll pass the path "/todos/{index}" to the useDoc hook:

const todoItemPath = "/todos/1";
//todoItem at index 1
const [todoItem, setTodoItem] = useDoc(todoItemPath);
Enter fullscreen mode Exit fullscreen mode

For performance reasons, it's recommended to make the path as specific as possible. A thumb rule is to fetch only the slice of doc that you have to read or modify.

Moving forward, in App component use useDoc hook to get the todos.You can now use todos array anywhere in your component. 😁


import React from "react";
import "./App.css";
import { useDoc } from "@syncstate/react";

function App() {

  const todoPath = "/todos";
  const [todos, setTodos] = useDoc(todoPath);

  return (
    <div className="container mt-5">
      <h2 className="text-center text-white">
        Multi User Todo Using SyncState
      </h2>
      <div className="row justify-content-center mt-5">
        <div className="col-md-8">
          <div className="card-hover-shadow-2x mb-3 card">
            <div className="card-header-tab card-header">
              <div className="card-header-title font-size-lg text-capitalize font-weight-normal">
                <i className="fa fa-tasks"></i>&nbsp;Task Lists
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Now, we'll create a component TodoItem which will take the path (todoItemPath) of each todo item as a prop and will show the content of the todo item.

Create a new folder named component in src directory and add a new file TodoItem.js containing the following code:

import React from "react";
import { useDoc } from "@syncstate/react";

function TodoItem({ todoItemPath }) {

  return (
    <div>
      <div className="d-flex align-content-center">
        <div
          className="d-flex align-items-center todoTitle"
        >
          <div style={{ width: "100%" }}>{todoItem.caption} </div>
        </div> 
      </div>
    </div>
  );
}

export default TodoItem;
Enter fullscreen mode Exit fullscreen mode

Revisit App.js and create a new array of items by mapping over the todo items from state and pass each todo's path as a prop to our TodoItem component:

import React from "react";
import "./App.css";
import TodoItem from "./components/TodoItem";
import { useDoc } from "@syncstate/react";

function App() {
  const todoPath = "/todos";
  const [todos, setTodos] = useDoc(todoPath);

  const todoList = todos.map((todoItem, index) => {
    return (
      <li key={todoItem.index} className="list-group-item">
        <TodoItem todo={todoItem} todoItemPath={todoPath + "/" + index} />
      </li>
    );
  });

  return (
    <div className="container mt-5">
      <h2 className="text-center text-white">
        Multi User Todo Using SyncState
      </h2>
      <div className="row justify-content-center mt-5">
        <div className="col-md-8">
          <div className="card-hover-shadow-2x mb-3 card">
            <div className="card-header-tab card-header">
              <div className="card-header-title font-size-lg text-capitalize font-weight-normal">
                <i className="fa fa-tasks"></i>&nbsp;Task Lists
              </div>
            </div>
            <div
              className="overflow-auto"
              style={{ height: "auto", maxHeight: "300px" }}
            >
              <div className="position-static">
                <ul className=" list-group list-group-flush">{todoList}</ul>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Creating Todo Items

Now, let’s give our app the power to create a new item.

Let’s build the addTodo function in the App component. Basically, the addTodo function will receive a todoItem and add it to our todos state.

In our App.js add the addTodo function:

//generate unique id
  const keyGenerator = () => "_" + Math.random().toString(36).substr(2, 9);
  const addTodo = (todoItem) => {
    setTodos((todos) => {
      let id = keyGenerator();
      todos.push({
        id: id,
        caption: todoItem,
        completed: false,
      });
      document.getElementsByClassName("input-todo")[0].value = "";
    });
  };
Enter fullscreen mode Exit fullscreen mode

It may seem like we are mutating our todos state directly in the setTodo function but we are updating the state using Immer.js which generates JSON patches for our internal reducers.

Now, add another component named AddTodo to the components folder. AddTodo component gives us the functionality of sending the input to the addTodo function.

import React, { useState } from "react";

function AddTodo({ addTodo }) {
  const [input, setInput] = useState("");

  return (
    <div
      className="d-block text-right card-footer d-flex"
      style={{ padding: "0.75rem" }}
    >
      <div className=" position-relative col " style={{ paddingLeft: "13px" }}>
        <input
          type="text"
          className="form-control input-todo"
          value={input}
          onChange={(e) => {
            setInput(e.target.value);
          }}
          onKeyPress={(event) => {
            if (event.which === 13 || event.keyCode === 13) {
              addTodo(input);
              setInput("");
            }
          }}
          placeholder="Enter new todo"
        />

        <i
          className="fa fa-close"
          style={{
            position: "absolute",
            top: "25%",
            right: "25px",
          }}
          onClick={() => setInput("")}
        ></i>
      </div>
      <div className="ml-auto">
        <button
          type="button"
          className="border-0 btn-transition btn btn-outline-danger"
          onClick={(e) => {
            e.preventDefault();
            addTodo(input);
            setInput("");
          }}
        >
          Add Task
        </button>
      </div>
    </div>
  );
}

export default AddTodo;
Enter fullscreen mode Exit fullscreen mode

So far, the src/App.js file looks like this:

import React from "react";
import "./App.css";
import TodoItem from "./components/TodoItem";
import AddTodo from "./components/AddTodo";
import { useDoc } from "@syncstate/react";

function App() {
  const todoPath = "/todos";
  const [todos, setTodos] = useDoc(todoPath);

  //generate unique id
  const keyGenerator = () => "_" + Math.random().toString(36).substr(2, 9);
  const addTodo = (todoItem) => {
    setTodos((todos) => {
      let id = keyGenerator();
      todos.push({
        id: id,
        caption: todoItem,
        completed: false,
      });
      document.getElementsByClassName("input-todo")[0].value = "";
    });
  };

  const todoList = todos.map((todoItem, index) => {
    return (
      <li key={todoItem.index} className="list-group-item">
        <TodoItem todo={todoItem} todoItemPath={todoPath + "/" + index} />
      </li>
    );
  });

  return (
    <div className="container mt-5">
      <h2 className="text-center text-white">
        Multi User Todo Using SyncState
      </h2>
      <div className="row justify-content-center mt-5">
        <div className="col-md-8">
          <div className="card-hover-shadow-2x mb-3 card">
            <div className="card-header-tab card-header">
              <div className="card-header-title font-size-lg text-capitalize font-weight-normal">
                <i className="fa fa-tasks"></i>&nbsp;Task Lists
              </div>
            </div>
            <div
              className="overflow-auto"
              style={{ height: "auto", maxHeight: "300px" }}
            >
              <div className="position-static">
                <ul className=" list-group list-group-flush">{todoList}</ul>
              </div>
            </div>
            <AddTodo addTodo={addTodo} />
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

We're now able to add and view todo items. 😄

Alt Text

Updating To-Do Items

Let’s add the functionality to cross off an item on your to-do list when they are completed.

Add toggleTodo function and a button to toggle completed property of todoItem to true or false:

import React from "react";
import { useDoc } from "@syncstate/react";

function TodoItem({ todoItemPath }) {
  const [todos, setTodos] = useDoc("/todos", Infinity);
  const [todoItem, setTodoItem] = useDoc(todoItemPath);


  const toggleTodo = (completed) => {
    setTodoItem((todoItem) => {
      todoItem.completed = completed;
    });
  };

 const getTxtStyle = {
    textDecoration: todoItem.completed ? "line-through" : "none",
    marginLeft: "10px",
  };

  return (
    <div>
      <div className="d-flex align-content-center">
        <div
          className="custom-checkbox custom-control d-flex align-items-center"
          style={{ marginBottom: "2px" }}
        >
          <input
            type="checkbox"
            className="form-check-input"
            checked={todoItem.completed}
            onChange={(e) => {
              toggleTodo(e.target.checked);
            }}
          />
        </div>

        <div
          className="d-flex align-items-center todoTitle"
          style={getTxtStyle}
        >
          <div style={{ width: "100%" }}>{todoItem.caption} </div>
        </div>
      </div>
    </div>
  );
}

export default TodoItem;
Enter fullscreen mode Exit fullscreen mode

Deleting Todo Items

Let’s add the functionality to delete an item from your todo list.

Add deleteTodo function and a button to delete todoItem:

import React from "react";
import { useDoc } from "@syncstate/react";

function TodoItem({ todoItemPath }) {
  const [todos, setTodos] = useDoc("/todos", Infinity);
  const [todoItem, setTodoItem] = useDoc(todoItemPath);

 const deleteTodo = (id) => {
    let index;
    for (let i = 0; i < todos.length; i++) {
      if (todos[i].id === id) {
        index = i;
        break;
      }
    }
    setTodos((todos) => {
      todos.splice(index, 1);
    });
  };
  const toggleTodo = (completed) => {
    setTodoItem((todoItem) => {
      todoItem.completed = completed;
    });
  };

  const getTxtStyle = () => {
    return {
      textDecoration: todoItem.completed ? "line-through" : "none",
      marginLeft: "10px",
    };
  };

  return (
    <div>
      <div className="d-flex align-content-center">
        <div
          className="custom-checkbox custom-control d-flex align-items-center"
          style={{ marginBottom: "2px" }}
        >
          <input
            type="checkbox"
            className="form-check-input"
            checked={todoItem.completed}
            onChange={(e) => {
              toggleTodo(e.target.checked);
            }}
          />
        </div>

        <div
          className="d-flex align-items-center todoTitle"
          style={getTxtStyle()}
        >
          <div style={{ width: "100%" }}>{todoItem.caption} </div>
        </div>
        <div className="ml-auto d-flex align-items-center">
          <button
            className="border-0 btn-transition btn btn-outline-danger"
            onClick={() => {
              deleteTodo(todoItem.id);
            }}
          >
            <i className="fa fa-trash"></i>
          </button>
        </div>
      </div>
    </div>
  );
}

export default TodoItem;
Enter fullscreen mode Exit fullscreen mode

In deleteTodo function, we are finding the index of the todoItem we want to delete and then splicing the todos array in setTodo function.

Congratulations, your Todo App using SyncState is complete! 😍

Alt Text

Part 2: Syncing the app state with others using the remote-plugin

Remote plugin is in the experimental stage and not ready for use in production. We're working on it!

Creating a Node server and adding Socket

We need to set up a Socket connection between client and server so that data can flow both ways. We will be using the Express.js server which will be used as our backend for Sockets.

To install Socket, execute the following commands:

npm install socket.io
npm install socket.io-client
Enter fullscreen mode Exit fullscreen mode

Create a server folder in root directory and create a new file index.js in it.

Open the terminal and execute:

npm install express
Enter fullscreen mode Exit fullscreen mode

Set up Socket for your server:

var express = require("express");
var socket = require("socket.io");
const remote = new SyncStateRemote();
var server = app.listen(8000, function () {
  console.log("listening on port 8000");
});

var io = socket(server);

io.on("connection", async (socket) => {

});
Enter fullscreen mode Exit fullscreen mode

Get Socket for client and set it up in your src/index.js file:


import io from "socket.io-client";

//set up socket connection
let socket = io.connect("http://localhost:8000");
Enter fullscreen mode Exit fullscreen mode

Listening to the patches in the front-end and sending it over (via plugin)

Install SyncState Remote plugin for client :

npm install @syncstate/remote-client
Enter fullscreen mode Exit fullscreen mode

Every time you make a change in your state, a JSON patch is generated from your side and is sent to the server. SyncState processes these patches and sends them to other clients.

In your src/index.js file, add the following:

import * as remote from "@syncstate/remote-client";
Enter fullscreen mode Exit fullscreen mode

Initialise remote by passing [remote.createInitializer()] ****as an additional argument in createDocStore function:

const store = createDocStore({ todos: [] }, [remote.createInitializer()]);
Enter fullscreen mode Exit fullscreen mode

Enable remote plugin on the todos path of your document tree:

store.dispatch(remote.enableRemote("/todos"));
Enter fullscreen mode Exit fullscreen mode

Whenever a new user joins, they should get all the patches generated till now:

// send request to server to get patches everytime when new user joins
socket.emit("fetchDoc", "/todos");
Enter fullscreen mode Exit fullscreen mode

If you make any changes in your Doc tree, you need to observe the changes so that you can send them to the server.

store.observe observes the changes at the path and calls the listener function with the new changes/JSON patches:

store.observe(
  "doc",
  "/todos",
  (todos, change) => {
    if (!change.origin) { 
      //send json patch to the server
      socket.emit("change", "/todos", change);
    }
  },
  Infinity
);
Enter fullscreen mode Exit fullscreen mode

The server adds origin to the change object. If a client receives a patch from the server sent by another client, then we shouldn't send that to the server. The client should send its own patches.

After receiving the patches from server, dispatch them:

socket.on("change", (path, patch) => {

store.dispatch(remote.applyRemote(path, patch));

});
Enter fullscreen mode Exit fullscreen mode

The entire src/index.js file will look like this so far:

import React from "react";
import { createDocStore } from "@syncstate/core";
import { Provider } from "@syncstate/react";
import ReactDOM from "react-dom";
import App from "./App.js";
import "./index.css";
import io from "socket.io-client";
import reportWebVitals from "./reportWebVitals";
import * as remote from "@syncstate/remote-client";

const store = createDocStore({ todos: [] }, [remote.createInitializer()]);

//enable remote plugin
store.dispatch(remote.enableRemote("/todos"));

//setting up socket connection with the server
let socket = io.connect("http://localhost:8000");

// send request to server to get patches everytime when page reloads
socket.emit("fetchDoc", "/todos");

//observe the changes in store state
store.observe(
  "doc",
  "/todos",
  (todos, change) => {
    if (!change.origin) {
      //send json patch to the server
      socket.emit("change", "/todos", change);
    }
  },
  Infinity
);

//get patches from server and dispatch
socket.on("change", (path, patch) => {
  // console.log(patch);
  store.dispatch(remote.applyRemote(path, patch));
});

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Enter fullscreen mode Exit fullscreen mode

Consuming patches in the backend and merging it via plugin

Install SyncState Remote plugin for the server:

npm install @syncstate/remote-server
Enter fullscreen mode Exit fullscreen mode

In your server/index.js file, create SyncState Remote instance:

const { SyncStateRemote } = require("@syncstate/remote-server");
const remote = new SyncStateRemote();
Enter fullscreen mode Exit fullscreen mode

We need to store the patches somewhere so that whenever a new client joins, they get all the patches. Here, we'll be using temporary storage for patches. Ideally, it should be a database.

Create a new file PatchManager.js in the server folder and add the following code:

module.exports = class PatchManager {
  // patches;
  constructor() {
    this.projectPatchesMap = new Map();
  }

  store(projectId, path, patch) {
    // console.log("storing patch", patch, this.projectPatchesMap);
    const projectPatches = this.projectPatchesMap.get(projectId);

    if (projectPatches) {
      const pathPatches = projectPatches.get(path);

      if (pathPatches) {
        pathPatches.push(patch);
      } else {
        projectPatches.set(path, [patch]);
      }
    } else {
      const pathPatchesMap = new Map();
      pathPatchesMap.set(path, [patch]);
      this.projectPatchesMap.set(projectId, pathPatchesMap);
    }
  }

  getAllPatches(projectId, path) {
    const projectPatches = this.projectPatchesMap.get(projectId);
    if (projectPatches) {
      const pathPatches = projectPatches.get(path);

      return pathPatches ? pathPatches : [];
    }

    return [];
  }
};
Enter fullscreen mode Exit fullscreen mode

Sending all patches to the client on joining:

socket.on("fetchDoc", (path) => {
    //get all patches
 const patchesList = patchManager.getAllPatches(projectId, path);
  if (patchesList) {
      //send each patch to the client
      patchesList.forEach((change) => {
        socket.emit("change", path, change);
      });
    }
 });
Enter fullscreen mode Exit fullscreen mode

Whenever a patch is received, SyncState handles conflicting updates and broadcasts to other clients:


//patches recieved from the client
  socket.on("change", (path, change) => {
    change.origin = socket.id;

    //resolves conflicts internally
    remote.processChange(socket.id, path, change);
  });

//patches are ready to be sent
  const dispose = remote.onChangeReady(socket.id, (path, change) => {
    //store the patches in js runtime or a persistent storage
    patchManager.store(projectId, path, change);

    //broadcast the pathes to other clients
    socket.broadcast.emit("change", path, change);
  });
Enter fullscreen mode Exit fullscreen mode

The entire server/index.js file will look like this so far:

const express = require("express");
const socket = require("socket.io");
const { v4: uuidv4 } = require("uuid");
const PatchManager = require("./PatchManager");
const { SyncStateRemote } = require("@syncstate/remote-server");
const remote = new SyncStateRemote();
const app = express();
const server = app.listen(8000, function () {
  console.log("listening on port 8000");
});

const io = socket(server);
const projectId = uuidv4();  //generate unique id 

let patchManager = new PatchManager();

io.on("connection", async (socket) => {
  socket.on("fetchDoc", (path) => {
    //get all patches
    const patchesList = patchManager.getAllPatches(projectId, path);

    if (patchesList) {
      //send each patch to the client
      patchesList.forEach((change) => {
        socket.emit("change", path, change);
      });
    }
  });

  //patches recieved from the client
  socket.on("change", (path, change) => {
    change.origin = socket.id;

    //resolves conflicts internally
    remote.processChange(socket.id, path, change);
  });
//patches are ready to be sent
  const dispose = remote.onChangeReady(socket.id, (path, change) => {
    //store the patches in js runtime or a persistent storage
    patchManager.store(projectId, path, change);

    //broadcast the pathes to other clients
    socket.broadcast.emit("change", path, change);
  });
});
Enter fullscreen mode Exit fullscreen mode

Start the server by executing the following command:

cd server
node index.js
Enter fullscreen mode Exit fullscreen mode

Voila! Two browsers, side-by-side and in sync.

Alt Text

Alt Text

Top comments (0)