Core Concepts
What do we mean when we say SyncState is a document-based state management library?
Let's consider that your app state is represented by this JSON document below.
{
todos: [
{
caption: "Hello",
completed: false
}
],
filter: "all"
}
We can use a JSON patch to modify this document.
{
op: "add",
path: "/todos/1",
value: {
caption: "Hi!",
completed: false
}
}
Document after applying the JSON Patch:
{
todos: [
{
caption: "Hello",
completed: false
},
{
caption: "Hi!",
completed: false
}
],
filter: "all"
}
Applying a series of patches
We can apply a series of patches to successive documents. The following example starts with a document with empty todos
, applies a patch to add a todo item and then replaces the caption of the todo item from "Hello"
to "Hi"
.
How to build it?
Let's say we have a function that updates the document with JSON patches applyPatches(doc, patches)
Now, instead of mutating the document, we can replace this by a new document following the concepts of functional programming. applyPatches(doc, patches)
can return the new document.
const newDoc = applyPatches(doc, patches);
Let's describe the patch as a Redux action
{
type: "PATCHES",
payload: [{
op: "add",
path: "/todos/1",
value: {
caption: "Hi!",
completed: false
}
}]
}
If we store the document in a Redux store, dispatching this action will replace the document with a new one using a reducer.
function docReducer(state = {}, action) {
switch (action.type) {
case "PATCH":
return applyPatches(state, action.payload);
break;
default:
return state;
}
}
SyncState uses the above concept to store the app state as document in its internal Redux store and updates it using JSON patches as actions.
Simplifying it further with Immer
Writing patches for all user actions is cumbersome. So let's use Immer to handle generation and application of patches.
Auto-generating patches with Immer
Immer has a function produceWithPatches
which lets you perform mutations on a draft state and returns the new state with patches and inversePatches. Applying these patches to the original state will give a state which is identical to the new state returned by produceWithPatches
.
let [newDoc, patches, inversePatches] = produceWithPatches(doc, (draftDoc) => {
draftDoc.todos.push(todoItem);
});
Immer also provides applyPatches
function which works the same as described above.
Note: JSON patch patch in Immer here is similar (but not the same) to JSON patches RFC 6902. Instead of a string of keys/indexes joined by /, it is an array of keys/indexes.
Combining the above knowledge, let's see a simplified example for adding a todo.
import { produceWithPatches } from "immer";
function addTodo(store, todoItem) {
const doc = store.getState();
let [newDoc, patches, inversePatches] = produceWithPatches(
doc,
(draftDoc) => {
draftDoc.todos.push(todoItem);
}
);
store.dispatch({
type: "PATCHES",
payload: patches,
});
}
Now, you can simply call addTodo(store, {caption: "Hi!", completed: false})
and a JSON patch will be generated by Immer for the operation draftDoc.todos.push(todoItem)
and dispatched to the store.
In the reducer, we can use applyPatches
function from Immer.
applyPatches(state.document, action.payload);
This will return the resulting document after applying the patch.
We can generalize this further for any action.
function setDoc(store, callback) {
const doc = store.getState();
let [newDoc, patches, inversePatches] = produceWithPatches(doc, callback);
store.dispatch({
type: "PATCHES",
payload: patches,
});
}
// Call setDoc function and perform mutations on draft document received in the
// callback passed as second argument.
// setDoc function will dispatch generated patches for those mutations.
setDoc(store, (draftDoc) => {
draftDoc.filter = "completed";
draftDoc.todos.push({ caption: "Say hello!", completed: false });
});
We have simplified this such that any mutation can be done on draft state without worrying about patches, Redux actions or reducers for simple use cases.
Syncing documents (across Network & Threads)
Applying the same patches to identical documents will produce identical documents.
This comes in handy if you are building a multi-user realtime app.
However, with multi-user realtime apps we have a few challenges:
- Conflict resolution in case of simultaneous modification at same path.
- Conflict resolution in case of modification at a path by one client and replacement/deletion of a node at the same path by another client.
- Optimistic updates on clients and reversal when needed.
- Maintaining undo/redo history on clients with updates received from other clients.
While these challenges cannot be handled the same way for every app but we believe that the majority of the use cases are very common and can be provided as a plugin of SyncState.
Comparison with Redux
- SyncState uses
Redux
but doesn't usereact-redux
- We store patches (or actions) along with the state in the global store.
{
docState: {},
docPatches: []
}
- State can be computed by applying a series of patches.
- We pass down
paths
to components instead of a part of thestate
.
No react-redux
SyncState doesn't use react-redux
and it's connect()
method, mainly to achieve higher performance. With react-redux
, connect()
method is always executed on the mounted components even if the component doesn't need a re-render.
In this component tree, connect()
is called on all the connected components, even though only green one needs to be rerendered.
There are techniques to make the connect()
method performant with selection
and caching
but the underlying philosophy remains the same.
Kent C. Dodds has summarized the strategies of re-render as push and pull.
SyncState uses Redux but the re-renders are based on push-based state management like MobX & Recoil.
A component subscribes to a particular path in a SyncState store
// In React component TodoApp.jsx
// Fetch todos at path "/todos" and modify it using setTodos.
// Component subscribes to changes at "/todos" path.
const [todos, setTodos] = useDoc("/todos");
The component is rerendered whenever there are updates to the path that it subscribes to.
Components can pass down path as props to child components to subscribe to.
// In React component TodoApp.jsx
const todoPath = "/todos";
const [todos, setTodos] = useDoc(todoPath);
return todos.map((todoItem, index) => (
<TodoItem path={todoPath + "/" + index} />
));
// In React component TodoItem.jsx
// Fetch todoItem at path received as a prop => "/todos/<index>"
// and modify it using setTodoItem.
// Component subscribes to changes at "/todos/<index>" path.
const [todoItem, setTodoItem] = useDoc(props.path);
Comparison with Recoil
- Recoil has atoms which are units of state. They are not part of a single state tree or document like in SyncState.
- SyncState uses the performant re-renders, much like Recoil. Recoil re-renders a component when the atom it subscribes to is updated whereas SyncState re-renders a component when the state at the path it subscribes to is updated in the document.
Comparison with MobX & MST
- SyncState uses event listeners for performant re-renders like MobX. MobX uses observable value, SyncState uses document paths.
- SyncState works with immutable states unlike MobX. However, SyncState allows mutations which happen on a draft state using Immer while maintaining immutability internally.
- With MobX, we generally follow Object Oriented programming concepts while SyncState isn't purely Object Oriented. We don't add methods to our data models. And since all the mutations are via patches, we don't need to worry if we are mutating or not mutating the original object. The re-renders are based on events that carry the path at which the data was changed.
Summary
- SyncState is based on Redux & Immer. It uses JSON patches for actions.
- Also, it doesn't use
connect()
to connect state to React components, it's based on events (actions) for greater performance. It uses auseDoc()
hook that listens to the updates on thepath
(push strategy) that the component is listening to and forces an update. (like Recoil). - We don't pass down states to components but use paths instead. It works like an ID that can be used to re-render the components. It also helps in not maintaining
indexes
on lists with actions like Redux.