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.
Close an issue. The list and the detail pane update together — no flash of one view lagging.
import { useController, useSuspense } from '@data-client/react'; import { IssueResource } from './IssueResource'; function IssuePage() { const ctrl = useController(); const issues = useSuspense(IssueResource.getList, { repoId: '1' }); const [id, setId] = React.useState(issues[0].id); const issue = useSuspense(IssueResource.get, { id }); const handleToggle = () => ctrl.fetch( IssueResource.partialUpdate, { id }, { state: issue.state === 'open' ? 'closed' : 'open' }, ); return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <div> {issues.map(item => ( <div key={item.pk()} className="listItem" style={{ cursor: 'pointer' }} onClick={() => setId(item.id)} > {item.id === id ? <b>{item.title}</b> : item.title} <small>{item.state}</small> </div> ))} </div> <div> <div>{issue.title}</div> <p> <small>{issue.state}</small> </p> <button onClick={handleToggle}> {issue.state === 'open' ? 'Close' : 'Reopen'} </button> </div> </div> ); } render(<IssuePage />);
Create
Created entities are immediately available. They are added to existing Collections with .push, .unshift, or .assign.
Open an issue. It appears in the list and is immediately readable with get — never invisible, never an orphan.
import { useController, useSuspense } from '@data-client/react'; import { IssueResource } from './IssueResource'; function IssuePage() { const ctrl = useController(); const issues = useSuspense(IssueResource.getList, { repoId: '1' }); const issue = useSuspense(IssueResource.get, { id: issues[issues.length - 1].id, }); const handleKeyDown = e => { if (e.key === 'Enter' && e.currentTarget.value.trim()) { ctrl.fetch(IssueResource.getList.push, { repoId: '1', title: e.currentTarget.value, }); e.currentTarget.value = ''; } }; return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <div> {issues.map(item => ( <div key={item.pk()} className="listItem"> {item.title} </div> ))} <div className="listItem nogap"> <TextInput size="small" placeholder="New issue" onKeyDown={handleKeyDown} /> </div> </div> <div> <small>Newest</small> <div>{issue.title}</div> <small>{issue.state}</small> </div> </div> ); } render(<IssuePage />);
Delete
schema.Invalidate removes the entity. Resource.delete provides such an endpoint.
Delete an issue. It disappears from the list and the detail pane in the same commit.
import { useController, useSuspense } from '@data-client/react'; import { IssueResource } from './IssueResource'; function IssuePage() { const ctrl = useController(); const issues = useSuspense(IssueResource.getList, { repoId: '1' }); const [id, setId] = React.useState(issues[0]?.id); const selected = issues.find(item => item.id === id) ?? issues[0]; const issue = useSuspense( IssueResource.get, selected ? { id: selected.id } : null, ); const handleDelete = () => ctrl.fetch(IssueResource.delete, { id: selected.id }); return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <div> {issues.map(item => ( <div key={item.pk()} className="listItem" style={{ cursor: 'pointer' }} onClick={() => setId(item.id)} > {item === selected ? <b>{item.title}</b> : item.title} </div> ))} </div> <div> {issue ? <div className="listItem nogap"> {issue.title} <CancelButton onClick={handleDelete} /> </div> : <small>No issues</small>} </div> </div> ); } render(<IssuePage />);
Rollback
Optimistic updates apply as that same snapshot. If the network fails, they roll back as that snapshot.
Close an issue. It flips immediately, then snaps back when the server errors.
import { useController, useSuspense } from '@data-client/react'; import { IssueResource } from './IssueResource'; function IssuePage() { const ctrl = useController(); const issues = useSuspense(IssueResource.getList, { repoId: '1' }); const [id, setId] = React.useState(issues[0].id); const issue = useSuspense(IssueResource.get, { id }); const handleToggle = () => ctrl.fetch( IssueResource.partialUpdate, { id }, { state: issue.state === 'open' ? 'closed' : 'open' }, ); return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <div> {issues.map(item => ( <div key={item.pk()} className="listItem" style={{ cursor: 'pointer' }} onClick={() => setId(item.id)} > {item.id === id ? <b>{item.title}</b> : item.title} <small>{item.state}</small> </div> ))} </div> <div> <div>{issue.title}</div> <p> <small>{issue.state}</small> </p> <button onClick={handleToggle}> {issue.state === 'open' ? 'Close' : 'Reopen'} </button> </div> </div> ); } render(<IssuePage />);
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.
Buy DOGE. The trade list and the account balance update together.
import { Entity, resource } from '@data-client/rest'; import { Account } from './AccountResource'; export class Trade extends Entity { id = ''; amount = 0; coin = ''; static key = 'Trade'; } export const TradeResource = resource({ path: '/trade/:id', schema: Trade, }).extend(Base => ({ create: Base.getList.push.extend({ schema: { trade: Base.getList.push.schema, account: Account, }, }), }));
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 issue showing two different values.
Identity
Entity.pk() is the unique index. The same issue from getList and get is the same object — the same value, wherever it is embedded.
Select an issue, then close it. fromList === get stays true.
import { useController, useSuspense } from '@data-client/react'; import { IssueResource } from './IssueResource'; function IssuePage() { const ctrl = useController(); const issues = useSuspense(IssueResource.getList, { repoId: '1' }); const [id, setId] = React.useState(issues[0].id); const issue = useSuspense(IssueResource.get, { id }); const fromList = issues.find(item => item.id === id); const handleToggle = () => ctrl.fetch( IssueResource.partialUpdate, { id }, { state: issue.state === 'open' ? 'closed' : 'open' }, ); return ( <div> <p> <small>fromList === get: {String(fromList === issue)}</small> </p> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <div> {issues.map(item => ( <div key={item.pk()} className="listItem" style={{ cursor: 'pointer' }} onClick={() => setId(item.id)} > {item.id === id ? <b>{item.title}</b> : item.title} <small>{item.state}</small> </div> ))} </div> <div> <div>{issue.title}</div> <p> <small>{issue.state}</small> </p> <button onClick={handleToggle}> {issue.state === 'open' ? 'Close' : 'Reopen'} </button> </div> </div> </div> ); } render(<IssuePage />);
Collections
When Collection.argsKey and Collection.nestKey return the same shape, a nested list and a top-level list are the same array.
Close an issue. repo.issues === getList stays true, and both columns update.
import { Entity, RestEndpoint, Collection } from '@data-client/rest'; export class Issue extends Entity { id = ''; repoId = ''; title = ''; state: 'open' | 'closed' = 'open'; static key = 'Issue'; } export const repoIssues = new Collection([Issue], { argsKey: ({ repoId }: { repoId?: string }) => ({ repoId }), nestKey: (parent: { id: string }) => ({ repoId: parent.id }), }); export const getIssues = new RestEndpoint({ path: '/issues', searchParams: {} as { repoId?: string }, schema: repoIssues, }); export const updateIssue = new RestEndpoint({ path: '/issues/:id', method: 'PATCH', schema: Issue, getOptimisticResponse(snap, { id }, body) { const cur = snap.get(Issue, { id }); if (!cur) throw snap.abort; return { ...cur, ...body }; }, });
Query
Query derived values stay consistent for the same reason — they read the entity table, not a copy.
Close issues. The open count updates without refetching.
import { Entity, resource, Query } from '@data-client/rest'; export class Issue extends Entity { id = ''; repoId = ''; title = ''; state: 'open' | 'closed' = 'open'; static key = 'Issue'; } export const IssueResource = resource({ path: '/issues/:id', searchParams: {} as { repoId?: string } | undefined, schema: Issue, optimistic: true, }); export const openCount = new Query( IssueResource.getList.schema, entries => entries.filter(issue => issue.state === 'open').length, );
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 Alice closed this. The list and the detail pane update — no copy left behind.
import { useController, useSuspense } from '@data-client/react'; import { Issue, IssueResource } from './IssueResource'; function IssuePage() { const ctrl = useController(); const issues = useSuspense(IssueResource.getList, { repoId: '1' }); const [id, setId] = React.useState(issues[0].id); const issue = useSuspense(IssueResource.get, { id }); const handlePush = () => ctrl.set(Issue, { id }, current => ({ ...current, state: current.state === 'open' ? 'closed' : 'open', })); return ( <div> <button onClick={handlePush}> {issue.state === 'open' ? 'Alice closed this' : 'Alice reopened this'} </button> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1em', }} > <div> {issues.map(item => ( <div key={item.pk()} className="listItem" style={{ cursor: 'pointer' }} onClick={() => setId(item.id)} > {item.id === id ? <b>{item.title}</b> : item.title} <small>{item.state}</small> </div> ))} </div> <div> <div>{issue.title}</div> <p> <small>{issue.state}</small> </p> </div> </div> </div> ); } render(<IssuePage />);
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.
Close an issue. list and query in that row always agree.
import { useController, useQuery, useSuspense } from '@data-client/react'; import { Issue, IssueResource } from './IssueResource'; export default function IssueRow({ id }: { id: string }) { const ctrl = useController(); const fromList = useSuspense(IssueResource.getList, { repoId: '1', }).find(issue => issue.id === id); const fromQuery = useQuery(Issue, { id }); if (!fromList) return null; const handleToggle = () => ctrl.fetch( IssueResource.partialUpdate, { id }, { state: fromList.state === 'open' ? 'closed' : 'open' }, ); return ( <div className="listItem"> {fromList.title} <button onClick={handleToggle}> {fromList.state === 'open' ? 'Close' : 'Reopen'} </button> <small> list={fromList.state} query={fromQuery?.state} same render= {String(fromList.state === fromQuery?.state)} </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 close, an inline
edit) commits to the server. Use a form when the friction is the point —
publish, purchase.
Close some issues, type a draft comment, then simulate a crash. Data Client refetches from the server and the closes are still there. The draft 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.