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.

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

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

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

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

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

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

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

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>
  );
}
🔴 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 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 />);
🔴 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.