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
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
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> 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 access the store in your components using the useDoc hook:
import { useDoc } from "@syncstate/react";
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);
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);
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> 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> 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 = "";
});
};
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;
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> 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. 😄
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! 😍
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
Create a server folder in root directory and create a new file index.js in it.
Open the terminal and execute:
npm install express
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) => {
});
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");
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
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";
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 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");
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
);
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();
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 [];
}
};
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);
});
}
});
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);
});
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);
});
});
Start the server by executing the following command:
cd server
node index.js
Top comments (0)