Skip to main content

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. Data Client updates the list and the detail together. Typical independent caches close the detail and leave the list open.

import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
import { useTypicalUpdate } from './TypicalCache';

function IssuePage() {
  const ctrl = useController();
  const issues = useSuspense(IssueResource.getList, { repoId: '1' });
  const typical = useTypicalUpdate(issues);
  const [id, setId] = React.useState(issues[0].id);
  const issue = useSuspense(IssueResource.get, { id });
  const handleToggle = () => {
    const state = issue.state === 'open' ? 'closed' : 'open';
    ctrl.fetch(IssueResource.partialUpdate, { id }, { state });
    typical.update(id, { state });
  };
  return (
    <div>
      <div className="acidToolbar">
        <button onClick={handleToggle}>
          {issue.state === 'open' ? 'Close' : 'Reopen'}
        </button>
      </div>
      <AcidCompare>
        <AcidPane title="Data Client" subtitle="one shared store">
          <div className="acidSplit">
            <div>
              <small>List</small>
              {issues.map(item => (
                <IssueRow
                  key={item.pk()}
                  title={item.title}
                  state={item.state}
                  selected={item.id === id}
                  onClick={() => setId(item.id)}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Detail</small>
              <div>{issue.title}</div>
              <IssueState state={issue.state} />
            </div>
          </div>
        </AcidPane>
        <AcidPane
          title="Typical caches"
          subtitle="independent copies"
          torn={typical.torn(id)}
        >
          <div className="acidSplit">
            <div>
              <small>List</small>
              {typical.list.map(item => (
                <IssueRow
                  key={item.id}
                  title={item.title}
                  state={item.state}
                  selected={item.id === id}
                  onClick={() => setId(item.id)}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Detail</small>
              <div>{typical.view(id).title}</div>
              <IssueState state={typical.view(id).state} />
            </div>
          </div>
        </AcidPane>
      </AcidCompare>
    </div>
  );
}
render(<IssuePage />);
🔴 Live Preview
Store

Create

Created entities are immediately available. They are added to existing Collections with .push, .unshift, or .assign.

Open an issue. Data Client adds it to the list and the newest detail. Typical independent caches show it in detail while the list still misses it.

import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
import { useTypicalCreate } from './TypicalCache';

function IssuePage() {
  const ctrl = useController();
  const issues = useSuspense(IssueResource.getList, { repoId: '1' });
  const typical = useTypicalCreate(issues);
  const newest = issues[issues.length - 1];
  const issue = useSuspense(IssueResource.get, { id: newest.id });
  const handleKeyDown = e => {
    if (e.key === 'Enter' && e.currentTarget.value.trim()) {
      const title = e.currentTarget.value;
      ctrl.fetch(IssueResource.getList.push, { repoId: '1', title });
      typical.create(title);
      e.currentTarget.value = '';
    }
  };
  return (
    <div>
      <div className="acidToolbar">
        <TextInput
          size="small"
          placeholder="New issue"
          onKeyDown={handleKeyDown}
        />
      </div>
      <AcidCompare>
        <AcidPane title="Data Client" subtitle="one shared store">
          <div className="acidSplit">
            <div>
              <small>List</small>
              {issues.map(item => (
                <IssueRow
                  key={item.pk()}
                  title={item.title}
                  state={item.state}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Newest</small>
              <div>{issue.title}</div>
              <IssueState state={issue.state} />
            </div>
          </div>
        </AcidPane>
        <AcidPane
          title="Typical caches"
          subtitle="independent copies"
          torn={typical.torn}
        >
          <div className="acidSplit">
            <div>
              <small>List</small>
              {typical.list.map(item => (
                <IssueRow
                  key={item.id}
                  title={item.title}
                  state={item.state}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Newest</small>
              {typical.orphan ?
                <>
                  <div>{typical.orphan.title}</div>
                  <IssueState state="open" />
                </>
              : <>
                  <div>{newest.title}</div>
                  <IssueState state={newest.state} />
                </>
              }
            </div>
          </div>
        </AcidPane>
      </AcidCompare>
    </div>
  );
}
render(<IssuePage />);
🔴 Live Preview
Store

Delete

schema.Invalidate removes the entity. Resource.delete provides such an endpoint.

Delete an issue. Data Client removes it from the list and the detail together. Typical independent caches clear the detail and leave a ghost in the list.

import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
import { useTypicalDelete } from './TypicalCache';

function IssuePage() {
  const ctrl = useController();
  const issues = useSuspense(IssueResource.getList, { repoId: '1' });
  const typical = useTypicalDelete(issues);
  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 });
    typical.remove(selected.id);
  };
  return (
    <AcidCompare>
      <AcidPane title="Data Client" subtitle="one shared store">
        <div className="acidSplit">
          <div>
            <small>List</small>
            {issues.map(item => (
              <IssueRow
                key={item.pk()}
                title={item.title}
                state={item.state}
                selected={item.id === selected?.id}
                onClick={() => setId(item.id)}
              />
            ))}
          </div>
          <div className="acidReadout">
            <small>Detail</small>
            {issue ?
              <div className="listItem nogap">
                {issue.title}
                <CancelButton onClick={handleDelete} />
              </div>
            : <small>No issues</small>}
          </div>
        </div>
      </AcidPane>
      <AcidPane
        title="Typical caches"
        subtitle="independent copies"
        torn={Object.keys(typical.deleted).length > 0}
      >
        <div className="acidSplit">
          <div>
            <small>List</small>
            {typical.list.map(item => (
              <IssueRow
                key={item.id}
                title={item.title}
                state={item.state}
                selected={item.id === id}
                ghost={typical.deleted[item.id]}
                onClick={() => setId(item.id)}
              />
            ))}
          </div>
          <div className="acidReadout">
            <small>Detail</small>
            {typical.deleted[id] ?
              <small>Deleted</small>
            : <div className="listItem nogap">
                {typical.list.find(item => item.id === id)?.title}
                <CancelButton onClick={handleDelete} />
              </div>
            }
          </div>
        </div>
      </AcidPane>
    </AcidCompare>
  );
}
render(<IssuePage />);
🔴 Live Preview
Store

Rollback

Optimistic updates apply as that same snapshot. If the network fails, they roll back as that snapshot.

Close an issue. Data Client flips both views, then rolls both back on the 500. Typical independent caches roll the detail back and leave the list closed.

import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
import { useTypicalRollback } from './TypicalCache';

function IssuePage() {
  const ctrl = useController();
  const issues = useSuspense(IssueResource.getList, { repoId: '1' });
  const typical = useTypicalRollback(issues);
  const [id, setId] = React.useState(issues[0].id);
  const issue = useSuspense(IssueResource.get, { id });
  const handleToggle = () => {
    const state = issue.state === 'open' ? 'closed' : 'open';
    ctrl.fetch(IssueResource.partialUpdate, { id }, { state });
    typical.update(id, state);
  };
  return (
    <div>
      <div className="acidToolbar">
        <button onClick={handleToggle}>
          {issue.state === 'open' ? 'Close' : 'Reopen'}
        </button>
      </div>
      <AcidCompare>
        <AcidPane title="Data Client" subtitle="one shared store">
          <div className="acidSplit">
            <div>
              <small>List</small>
              {issues.map(item => (
                <IssueRow
                  key={item.pk()}
                  title={item.title}
                  state={item.state}
                  selected={item.id === id}
                  onClick={() => setId(item.id)}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Detail</small>
              <div>{issue.title}</div>
              <IssueState state={issue.state} />
            </div>
          </div>
        </AcidPane>
        <AcidPane
          title="Typical caches"
          subtitle="independent copies"
          torn={typical.torn(id)}
        >
          <div className="acidSplit">
            <div>
              <small>List</small>
              {typical.list.map(item => (
                <IssueRow
                  key={item.id}
                  title={item.title}
                  state={item.state}
                  selected={item.id === id}
                  onClick={() => setId(item.id)}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Detail</small>
              <div>{typical.view(id).title}</div>
              <IssueState state={typical.view(id).state} />
            </div>
          </div>
        </AcidPane>
      </AcidCompare>
    </div>
  );
}
render(<IssuePage />);
🔴 Live Preview
Store

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. Data Client records the trade and the new balance in one commit. Typical independent caches append the trade and leave the balance stale.

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,
    },
  }),
}));
🔴 Live Preview
Store

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. Data Client getList and get stay locked together. Typical independent caches keep two copies that drift.

import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
import { useTypicalIdentity } from './TypicalCache';

function IssuePage() {
  const ctrl = useController();
  const issues = useSuspense(IssueResource.getList, { repoId: '1' });
  const typical = useTypicalIdentity(issues);
  const [id, setId] = React.useState(issues[0].id);
  const issue = useSuspense(IssueResource.get, { id });
  const fromList = issues.find(item => item.id === id);
  const handleToggle = () => {
    const state = issue.state === 'open' ? 'closed' : 'open';
    ctrl.fetch(IssueResource.partialUpdate, { id }, { state });
    typical.update(id, { state });
  };
  return (
    <div>
      {issues.map(item => (
        <IssueRow
          key={item.pk()}
          title={item.title}
          state={item.state}
          selected={item.id === id}
          onClick={() => setId(item.id)}
        />
      ))}
      <div className="acidToolbar">
        <button onClick={handleToggle}>
          {issue.state === 'open' ? 'Close' : 'Reopen'}
        </button>
      </div>
      <AcidCompare>
        <AcidPane title="Data Client" subtitle="one shared store">
          <div className="acidSplit">
            <div className="acidReadout">
              <small>getList</small>
              <IssueState state={fromList.state} />
            </div>
            <div className="acidReadout">
              <small>get</small>
              <IssueState state={issue.state} />
            </div>
          </div>
        </AcidPane>
        <AcidPane
          title="Typical caches"
          subtitle="independent copies"
          torn={typical.torn(id)}
        >
          <div className="acidSplit">
            <div className="acidReadout">
              <small>getList</small>
              <IssueState
                state={typical.list.find(item => item.id === id).state}
              />
            </div>
            <div className="acidReadout">
              <small>get</small>
              <IssueState state={typical.view(id).state} />
            </div>
          </div>
        </AcidPane>
      </AcidCompare>
    </div>
  );
}
render(<IssuePage />);
🔴 Live Preview
Store

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. Data Client updates the repo page and the issues tab together. Typical independent caches update one list and leave the other open.

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 };
  },
});
🔴 Live Preview
Store

Query

Query derived values stay consistent for the same reason — they read the entity table, not a copy.

Close issues. Data Client drops the open count immediately. Typical independent caches keep a stale count.

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,
);
🔴 Live Preview
Store

Validation

Entity.validate() is the check constraint. Invalid responses are not committed.

Switch between payloads. Data Client rejects invalid articles and keeps the last good commit. Typical independent caches render the malformed fields.

Fixtures
GET /article/1
{"id":"1","title":"first"}
GET /article/2
{"id":"2"}
GET /article/3
{"id":"3","title":{"complex":"second","object":5}}
GET /raw-article/1
{"id":"1","title":"first"}
GET /raw-article/2
{"id":"2"}
GET /raw-article/3
{"id":"3","title":{"complex":"second","object":5}}
api/Article
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,
});
api/RawArticle
ArticlePage
Navigator
🔴 Live Preview
Store

Transports

The same entity is the same value whether it arrived from fetch, initial load, Controller.set(), or a websocket.

Click Alice closed this. Data Client updates the list and the detail. Typical independent caches update a local detail copy and leave the list behind.

import { useController, useSuspense } from '@data-client/react';
import { Issue, IssueResource } from './IssueResource';
import { useTypicalPush } from './TypicalCache';

function IssuePage() {
  const ctrl = useController();
  const issues = useSuspense(IssueResource.getList, { repoId: '1' });
  const typical = useTypicalPush(issues);
  const [id, setId] = React.useState(issues[0].id);
  const issue = useSuspense(IssueResource.get, { id });
  const handlePush = () => {
    const state = issue.state === 'open' ? 'closed' : 'open';
    ctrl.set(Issue, { id }, current => ({ ...current, state }));
    typical.update(id, { state });
  };
  return (
    <div>
      <div className="acidToolbar">
        <button onClick={handlePush}>
          {issue.state === 'open' ?
            'Alice closed this'
          : 'Alice reopened this'}
        </button>
      </div>
      <AcidCompare>
        <AcidPane title="Data Client" subtitle="one shared store">
          <div className="acidSplit">
            <div>
              <small>List</small>
              {issues.map(item => (
                <IssueRow
                  key={item.pk()}
                  title={item.title}
                  state={item.state}
                  selected={item.id === id}
                  onClick={() => setId(item.id)}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Detail</small>
              <div>{issue.title}</div>
              <IssueState state={issue.state} />
            </div>
          </div>
        </AcidPane>
        <AcidPane
          title="Typical caches"
          subtitle="independent copies"
          torn={typical.torn(id)}
        >
          <div className="acidSplit">
            <div>
              <small>List</small>
              {typical.list.map(item => (
                <IssueRow
                  key={item.id}
                  title={item.title}
                  state={item.state}
                  selected={item.id === id}
                  onClick={() => setId(item.id)}
                />
              ))}
            </div>
            <div className="acidReadout">
              <small>Detail</small>
              <div>{typical.view(id).title}</div>
              <IssueState state={typical.view(id).state} />
            </div>
          </div>
        </AcidPane>
      </AcidCompare>
    </div>
  );
}
render(<IssuePage />);
🔴 Live Preview
Store

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,
    };
  },
});
🔴 Live Preview
Store

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. Data Client only paints matching list/query pairs. Typical independent caches record a mixed-version paint.

import { useController, useQuery, useSuspense } from '@data-client/react';
import { Issue, IssueResource } from './IssueResource';

export default function IssueRow({
  id,
  onToggle,
}: {
  id: string;
  onToggle: () => void;
}) {
  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' },
    );
    onToggle();
  };
  return (
    <div className="issueRow">
      <span className="issueRowTitle">{fromList.title}</span>
      <button onClick={handleToggle}>
        {fromList.state === 'open' ? 'Close' : 'Reopen'}
      </button>
      <IssueState state={fromList.state} />
    </div>
  );
}
🔴 Live Preview
Store

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 the closes from the server. Typical independent caches lose the closes. Both lose the draft.

import { useController } from '@data-client/react';
import Session from './Session';

function App() {
  const ctrl = useController();
  const [session, setSession] = React.useState(0);
  const [draft, setDraft] = React.useState('');
  const handleCrash = async () => {
    await ctrl.resetEntireStore();
    setDraft('');
    setSession(s => s + 1);
  };
  return (
    <div>
      <div className="acidToolbar">
        <button onClick={handleCrash}>Simulate crash</button>
      </div>
      <AsyncBoundary fallback={<Loading />}>
        <Session
          key={session}
          draft={draft}
          setDraft={setDraft}
        />
      </AsyncBoundary>
    </div>
  );
}
render(<App />);
🔴 Live Preview
Store

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.

Reactivity

ACID makes writes trustworthy. useLive(), polling, and push keep the UI a live function of the store. Reactivity is how you watch the durable store; it is not a substitute for reaching it.