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

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

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

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

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

Validation

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

Switch between payloads. Only the valid article renders.

Fixtures
GET /article/1
{"id":"1","title":"first"}
GET /article/2
{"id":"2"}
GET /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,
});
ArticlePage
Navigator
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>,
);
🔴 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. 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 />);
🔴 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. 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>
  );
}
🔴 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 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 />);
🔴 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.