ACID for frontend data
Users expect clear cause and effect: actions have consequences, and those consequences are obvious. Things should not appear, disappear, or change on their own. A user's time is valuable — don't lose their work.
Relational databases call these guarantees ACID. The frontend store is that database for interactive data — but every durable write is asynchronous.
Reactive Data Client applies the same guarantees so every view agrees without refetching, mutations don't flash torn state, and crashes don't lose data that reached a durable store like a REST server or IndexedDB.
Normalization is what makes this possible.
Atomicity
A mutation is a single unit: it succeeds completely or fails completely. Other components never observe it halfway. That prevents temporal data tearing — flashes of inconsistent state as usages update one by one.
Update
Resource.update and Resource.partialUpdate merge the response into the one copy of that entity. Every consumer of that pk updates together. Read more about defining other update endpoints.
Toggle a todo. Both lists update at once — no flash of one list lagging.
import TodoList from './TodoList'; function TodoPage() { return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <TodoList /> <TodoList /> </div> ); } render(<TodoPage />);
Create
Created entities are immediately available. They are added to existing Collections with .push, .unshift, or .assign.
Add a todo. It appears in both lists together — never invisible, never an orphan, never a list hole.
import TodoList from './TodoList'; function TodoPage() { return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <TodoList /> <TodoList /> </div> ); } render(<TodoPage />);
Delete
schema.Invalidate removes the entity. Resource.delete provides such an endpoint.
Delete a todo. It disappears from both lists in the same commit.
import TodoList from './TodoList'; function TodoPage() { return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <TodoList /> <TodoList /> </div> ); } render(<TodoPage />);
Rollback
Optimistic updates apply as that same snapshot. If the network fails, they roll back as that snapshot.
Click add. The todo appears immediately, then vanishes when the server errors.
import { useController, useSuspense } from '@data-client/react'; import { TodoResource } from './TodoResource'; function TodoList() { const ctrl = useController(); const todos = useSuspense(TodoResource.getList, { userId: '1' }); const handleAdd = () => ctrl.fetch(TodoResource.getList.push, { userId: '1', title: 'New todo', }); return ( <div> {todos.map(todo => ( <div key={todo.pk()} className="listItem"> {todo.title} </div> ))} <button onClick={handleAdd}>Add todo</button> </div> ); } render(<TodoList />);
Side effects
When a mutation changes more than one resource, include every changed entity in the response. That is one commit. Invalidating and refetching the others can fail partway — a flash of torn state.
See mutation side-effects for the full pattern.
Add a todo. The list and the user's count update together.
import { Entity, RestEndpoint } from '@data-client/rest'; import { getTodos } from './api/Todo'; export class User extends Entity { id = ''; name = ''; todoCount = 0; static key = 'User'; } export const getUser = new RestEndpoint({ path: '/users/:id', schema: User, }); export const createTodo = getTodos.push.extend({ schema: { todo: getTodos.push.schema, user: User, }, });
import { useController, useSuspense } from '@data-client/react'; import { getTodos } from './api/Todo'; import { createTodo, getUser } from './api/User'; function TodoPage() { const ctrl = useController(); const user = useSuspense(getUser, { id: '1' }); const todos = useSuspense(getTodos, { userId: '1' }); const handleAdd = () => ctrl.fetch(createTodo, { userId: '1', title: 'New todo', }); return ( <div> <p> {user.name} has <b>{user.todoCount}</b> todos </p> {todos.map(todo => ( <div key={todo.pk()} className="listItem"> {todo.title} </div> ))} <button onClick={handleAdd}>Add todo</button> </div> ); } render(<TodoPage />);
Consistency
A write takes the store from one valid state to another. Invariants hold: one copy of each entity, relationships join, invalid data is rejected. That prevents data tearing — the same todo showing two different values.
Identity
Entity.pk() is the unique index. The same todo from getList and get is the same object — the same value, wherever it is embedded.
Select a todo, then toggle it. fromList === get stays true.
import { useController, useSuspense } from '@data-client/react'; import { TodoResource } from './TodoResource'; function TodoPage() { const ctrl = useController(); const todos = useSuspense(TodoResource.getList, { userId: '1' }); const [id, setId] = React.useState(todos[0].id); const todo = useSuspense(TodoResource.get, { id }); const fromList = todos.find(item => item.id === id); const handleChange = e => ctrl.fetch( TodoResource.partialUpdate, { id }, { completed: e.currentTarget.checked }, ); return ( <div> <p> <small>fromList === get: {String(fromList === todo)}</small> </p> {todos.map(item => ( <div key={item.pk()} className="listItem" style={{ cursor: 'pointer' }} onClick={() => setId(item.id)} > {item.id === id ? <b>{item.title}</b> : item.title} </div> ))} <label> <input type="checkbox" checked={todo.completed} onChange={handleChange} /> {todo.title} </label> </div> ); } render(<TodoPage />);
Collections
When Collection.argsKey and Collection.nestKey return the same shape, a nested list and a top-level list are the same array.
Toggle a todo. user.todos === getList stays true, and both columns update.
import { useController, useSuspense } from '@data-client/react'; import { getTodos, updateTodo } from './api/Todo'; import { getUser } from './api/User'; function TodoList() { const ctrl = useController(); const user = useSuspense(getUser, { id: '1' }); const todos = useSuspense(getTodos, { userId: '1' }); const handleChange = (todo, e) => ctrl.fetch( updateTodo, { id: todo.id }, { completed: e.currentTarget.checked }, ); return ( <div> <p> <small>user.todos === getList: {String(user.todos === todos)}</small> </p> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <div> <small>user.todos</small> {user.todos.map(todo => ( <div key={todo.pk()} className="listItem nogap"> <label> <input type="checkbox" checked={todo.completed} onChange={e => handleChange(todo, e)} /> {todo.title} </label> </div> ))} </div> <div> <small>getList</small> {todos.map(todo => ( <div key={todo.pk()} className="listItem nogap"> <label> <input type="checkbox" checked={todo.completed} onChange={e => handleChange(todo, e)} /> {todo.title} </label> </div> ))} </div> </div> </div> ); } render(<TodoList />);
Query
Query derived values stay consistent for the same reason — they read the entity table, not a copy.
Toggle todos. The remaining count updates without refetching.
import { useController, useQuery, useSuspense } from '@data-client/react'; import { remainingTodos, TodoResource } from './TodoResource'; function TodoList() { const ctrl = useController(); const todos = useSuspense(TodoResource.getList, { userId: '1' }); const remaining = useQuery(remainingTodos, { userId: '1' }); const handleChange = (todo, e) => ctrl.fetch( TodoResource.partialUpdate, { id: todo.id }, { completed: e.currentTarget.checked }, ); return ( <div> <p> <b>{remaining}</b> remaining </p> {todos.map(todo => ( <div key={todo.pk()} className="listItem nogap"> <label> <input type="checkbox" checked={todo.completed} onChange={e => handleChange(todo, e)} /> {todo.completed ? <strike>{todo.title}</strike> : todo.title} </label> </div> ))} </div> ); } render(<TodoList />);
Validation
Entity.validate() is the check constraint. Invalid responses are not committed.
Switch between payloads. Only the valid article renders.
{"id":"1","title":"first"}
{"id":"2"}
{"id":"3","title":{"complex":"second","object":5}}
export class Article extends Entity { id = ''; title = ''; static validate(processedEntity) { if (!Object.hasOwn(processedEntity, 'title')) return 'missing title field'; if (typeof processedEntity.title !== 'string') return 'title is wrong type'; } } export const getArticle = new RestEndpoint({ path: '/article/:id', schema: Article, });
import ArticlePage from './ArticlePage'; function Navigator() { const [id, setId] = React.useState('1'); return ( <div> <button value="1" onClick={e => setId(e.currentTarget.value)}> Valid </button> <button value="2" onClick={e => setId(e.currentTarget.value)}> Missing title </button> <button value="3" onClick={e => setId(e.currentTarget.value)}> Wrong type </button> <AsyncBoundary fallback={<Loading />}> <ArticlePage id={id} /> </AsyncBoundary> </div> ); } render( <ResetableErrorBoundary> <Navigator /> </ResetableErrorBoundary>, );
Transports
The same entity is the same value whether it arrived from fetch, initial load, Controller.set(), or a websocket.
Click simulate websocket. Both lists update — no copy left behind.
import { useController, useSuspense } from '@data-client/react'; import { Todo, TodoResource } from './TodoResource'; import TodoList from './TodoList'; function TodoPage() { const ctrl = useController(); const todos = useSuspense(TodoResource.getList, { userId: '1' }); const handlePush = () => { const todo = todos[0]; ctrl.set(Todo, { id: todo.id }, current => ({ ...current, title: `${current.title} (pushed)`, })); }; return ( <div> <button onClick={handlePush}>Simulate websocket</button> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <TodoList /> <TodoList /> </div> </div> ); } render(<TodoPage />);
Isolation
Concurrent work leaves the store as if it ran in sequence. A slower response cannot confuse a newer local edit.
Fetch order
Overlapping fetches complete in any order. Reactive Data Client pairs each optimistic update with its own request and commits in fetchedAt order. A late response cannot clobber a newer commit.
With other libraries this would show 0, then 2, then 1. Reactive Data Client keeps 0, 1, 2.
Click increment several times quickly.
import { CountEntity, getCount } from './count'; export const increment = new RestEndpoint({ path: '/api/count/increment', method: 'POST', body: undefined, name: 'increment', schema: CountEntity, getOptimisticResponse(snap) { const data = snap.get(CountEntity, {}); if (!data) throw snap.abort; return { count: data.count + 1, }; }, });
Optimistic updates amplify these races; Reactive Data Client handles them automatically.
Snapshots
All hooks in one render read the same snapshot, so the tree never paints mixed old and new values.
Toggle a todo. list and query in that row always agree.
import { useController, useQuery, useSuspense } from '@data-client/react'; import { Todo, TodoResource } from './TodoResource'; export default function TodoItem({ id }: { id: string }) { const ctrl = useController(); const fromList = useSuspense(TodoResource.getList, { userId: '1', }).find(todo => todo.id === id); const fromQuery = useQuery(Todo, { id }); if (!fromList) return null; const handleChange = e => ctrl.fetch( TodoResource.partialUpdate, { id }, { completed: e.currentTarget.checked }, ); return ( <div className="listItem nogap"> <label> <input type="checkbox" checked={fromList.completed} onChange={handleChange} /> {fromList.title} </label> <small> list={String(fromList.completed)} query= {String(fromQuery?.completed)} same render= {String(fromList.completed === fromQuery?.completed)} </small> </div> ); }
Durability
Once work is committed, it stays committed through a crash or a closed tab. Storing in memory is not enough — mutations must reach an async API. Later retrievals reflect those updates.
REST
ctrl.fetch is the commit path. Saving as you go (a toggle, an inline
edit) commits to the server. Use a form when the friction is the point —
publish, purchase.
Toggle some todos, then simulate a crash. Data Client refetches from the server and the work is still there. The local-only note is gone.
import { useController } from '@data-client/react'; import Session from './Session'; function App() { const ctrl = useController(); const [session, setSession] = React.useState(0); const handleCrash = async () => { await ctrl.resetEntireStore(); setSession(s => s + 1); }; return ( <div> <button onClick={handleCrash}>Simulate crash</button> <AsyncBoundary fallback={<Loading />}> <Session key={session} /> </AsyncBoundary> </div> ); } render(<App />);
In-flight optimistic updates are not the durable commit — the fetch is.
IndexedDB
A persist Manager can replicate confirmed state to IndexedDB for offline reloads. Restore it with DataProvider's initialState. Drop in-flight optimistic updates — they are not cloneable, and they are not the ack.