Multi-User Todo App Using SyncState & React

Multi-User Todo App Using SyncState & React

·

14 min read

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 or 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

Next, navigate to the project directory:

cd sync-multi-user-todo

Run the project:

npm run start

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"
 />

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%";
}

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

SyncState provides createDocStore and Provider

import { createDocStore } from "@syncstate/core";
import { Provider } from "@syncstate/react";

Import createDocStore and Provider in your index.js file.


Creating the store

Note: 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: [] });

Wrap your app with Provider and pass the store prop:

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

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;

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

Reading To-Do Items

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

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

Note: 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);

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);

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 the app component use the 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;

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;

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;

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 = "";
    });
  };

Note: 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 called 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" //addText
          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;

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;

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

Screenshot_1942-09-08_at_9.20.18_PM.png

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;

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;

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! 😍

Screen_Recording_1942-09-08_at_10.49.52_PM.gif

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

Note: 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

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

Open the terminal and execute:

npm install express
npm install uuidv4

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) => {

});

Set up Socket on the client side as well. Add the following code in src/index.js:


import io from "socket.io-client";

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

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

Note: 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.

import * as remote from "@syncstate/remote-client";

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

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

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

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

Whenever you reload or a new user joins, you should get all patches generated till now:

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

If 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
);

Note: 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));

});

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();

Consuming patches in the backend and merging it via plugin

Install SyncState Remote plugin for the server:

npm install @syncstate/remote-server

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

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

Note: We need to store the patches somewhere so that whenever a new client joins, it gets all the patches.

Here, we'll be using a 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 [];
  }
};

Sending all patches whenever a new client joins or browser reloads:

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);
      });
    }
 });

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);
  });

For generating unique ids, execute the following command:

npm install uuidv4

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);
  });

  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);
  });
});

Start the server by executing the following command:

cd server
node index.js

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

Todo.gif

SyncState-bnr-1200-630.png

This article was written by Cyrus P and edited by Kavya V.