Skip to main content

Reactive Data Client

Reactive Mutations

Render data with useSuspense(). Then mutate with Controller.fetch().

This updates all usages atomically and immediately with zero additional fetches. Reactive Data Client automatically ensures data consistency and integrity globally including even the most challenging race conditions.

Editor
import { Entity, createResource } from '@data-client/rest';

export class Post extends Entity {
  id = 0;
  userId = 0;
  title = '';
  body = '';
  pk() {
    return `${this.id}`;
  }
}
export const PostResource = createResource({
  urlPrefix: 'https://jsonplaceholder.typicode.com',
  path: '/posts/:id',
  schema: Post,
  searchParams: {} as { userId?: string | number } | undefined,
  optimistic: true,
});

export class User extends Entity {
  id = 0;
  name = '';
  username = '';
  email = '';
  phone = '';
  website = '';

  get profileImage() {
    return `https://i.pravatar.cc/64?img=${this.id + 4}`;
  }

  pk() {
    return `${this.id}`;
  }
}
export const UserResource = createResource({
  urlPrefix: 'https://jsonplaceholder.typicode.com',
  path: '/users/:id',
  schema: User,
  optimistic: true,
});
import { UserResource, type Post } from './resources';

export default function PostItem({ post }: { post: Post }) {
  const author = useSuspense(UserResource.get, { id: post.userId });
  return (
    <div className="listItem spaced">
      <Avatar src={author.profileImage} />
      <div>
        <h4>{post.title}</h4>
        <small>by {author.name}</small>
      </div>
    </div>
  );
}
import { UserResource } from './resources';

export default function ProfileEdit({ userId }: { userId: number }) {
  const user = useSuspense(UserResource.get, { id: userId });
  const controller = useController();
  const handleChange = e =>
    controller.fetch(
      UserResource.partialUpdate,
      { id: userId },
      { name: e.currentTarget.value },
    );
  return (
    <div>
      <label>
        Name:{' '}
        <input
          type="text"
          value={user.name}
          onChange={handleChange}
        />
      </label>
    </div>
  );
}
import PostItem from './PostItem';
import ProfileEdit from './ProfileEdit';
import { PostResource } from './resources';

function PostList() {
  const userId = 1;
  const posts = useSuspense(PostResource.getList, { userId });
  return (
    <div>
      <ProfileEdit userId={userId} />
      <br />
      {posts.map(post => (
        <PostItem key={post.pk()} post={post} />
      ))}
    </div>
  );
}
render(<PostList />);
🔴 Live Preview
Store
Editor
import { GQLEndpoint, GQLEntity, schema } from '@data-client/graphql';

const gql = new GQLEndpoint('/');

export class User extends GQLEntity {
  name = '';
  username = '';
  email = '';
  phone = '';
  website = '';

  get profileImage() {
    return `https://i.pravatar.cc/64?img=${this.id + 4}`;
  }
}

export class Post extends GQLEntity {
  title = '';
  body = '';
  author = User.fromJS();

  static schema = {
    author: User,
  };
}

export const PostResource = {
  getList: gql.query(
    `query GetPosts($userId: ID) {
    post {
      id
      title
      body
      user {
        id
        name
        username
        email
        phone
        website
      }
    }
  }
`,
    { posts: new schema.Collection([Post]) },
  ),
};

export const UserResource = {
  get: gql.query(
    `query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      username
      email
      phone
      website
    }
  }
`,
    { user: User },
  ),
  update: gql.mutation(
    `mutation UpdateUser($user: User!) {
      updateUser(user: $user) {
        id
        name
        username
        email
        phone
        website
    }
  }`,
    { updateUser: User },
  ),
};
import { type Post } from './resources';

export default function PostItem({ post }: { post: Post }) {
  return (
    <div className="listItem spaced">
      <Avatar src={post.author.profileImage} />
      <div>
        <h4>{post.title}</h4>
        <small>by {post.author.name}</small>
      </div>
    </div>
  );
}
import { UserResource } from './resources';

export default function ProfileEdit({ userId }: { userId: number }) {
  const { user } = useSuspense(UserResource.get, { id: userId });
  const controller = useController();
  const handleChange = e =>
    controller.fetch(UserResource.update, {
      id: userId,
      name: e.currentTarget.value,
    });
  return (
    <div>
      <label>
        Name:{' '}
        <input
          type="text"
          value={user.name}
          onChange={handleChange}
        />
      </label>
    </div>
  );
}
import PostItem from './PostItem';
import ProfileEdit from './ProfileEdit';
import { PostResource } from './resources';

function PostList() {
  const userId = 1;
  const { posts } = useSuspense(PostResource.getList, { userId });
  return (
    <div>
      <ProfileEdit userId={userId} />
      <br />
      {posts.map(post => (
        <PostItem key={post.pk()} post={post} />
      ))}
    </div>
  );
}
render(<PostList />);
🔴 Live Preview
Store

Structured data

Data consistency, performance, and typesafety scale even as your data becomes more complex.

Creates and deletes reactively update the correct lists, even when those lists are nested inside other objects.

Model even the most complex data with polymorphic and unbounded object/maps support.

Editor
import { Entity, schema, createResource } from '@data-client/rest';

export class Todo extends Entity {
  id = 0;
  userId = 0;
  title = '';
  completed = false;
  pk() {
    return `${this.id}`;
  }
}
export const TodoResource = createResource({
  urlPrefix: 'https://jsonplaceholder.typicode.com',
  path: '/todos/:id',
  searchParams: {} as { userId?: string | number } | undefined,
  schema: Todo,
  optimistic: true,
});

export class User extends Entity {
  id = 0;
  name = '';
  username = '';
  email = '';
  website = '';
  todos: Todo[] = [];

  get profileImage() {
    return `https://i.pravatar.cc/64?img=${this.id + 4}`;
  }

  pk() {
    return `${this.id}`;
  }

  static schema = {
    todos: new schema.Collection([Todo], {
      nestKey: (parent, key) => ({
        userId: parent.id,
      }),
    }),
  };
}
export const UserResource = createResource({
  urlPrefix: 'https://jsonplaceholder.typicode.com',
  path: '/users/:id',
  schema: User,
  optimistic: true,
});
import { TodoResource, type Todo } from './resources';

export default function TodoItem({ todo }: { todo: Todo }) {
  const controller = useController();
  const handleChange = e =>
    controller.fetch(
      TodoResource.partialUpdate,
      { id: todo.id },
      { completed: e.currentTarget.checked },
    );
  const handleDelete = () =>
    controller.fetch(TodoResource.delete, {
      id: todo.id,
    });
  return (
    <div className="listItem nogap">
      <label>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={handleChange}
        />
        {todo.completed ? <strike>{todo.title}</strike> : todo.title}
      </label>
      <CancelButton onClick={handleDelete} />
    </div>
  );
}
import { TodoResource } from './resources';

export default function NewTodo({ userId }: { userId: number }) {
  const controller = useController();
  const handleKeyDown = async e => {
    if (e.key === 'Enter') {
      controller.fetch(TodoResource.getList.push, {
        userId,
        title: e.currentTarget.value,
      });
      e.currentTarget.value = '';
    }
  };
  return (
    <div className="listItem nogap">
      <label>
        <input type="checkbox" name="new" checked={false} disabled />
        <input type="text" onKeyDown={handleKeyDown} />
      </label>
      <CancelButton />
    </div>
  );
}
import NewTodo from './NewTodo';
import { type Todo } from './resources';
import TodoItem from './TodoItem';

export default function TodoList({ todos, userId }: Props) {
  return (
    <div>
      {todos.map(todo => (
        <TodoItem key={todo.pk()} todo={todo} />
      ))}
      <NewTodo userId={userId} />
    </div>
  );
}
interface Props {
  todos: Todo[];
  userId: number;
}
import { UserResource } from './resources';
import TodoList from './TodoList';

function UserList() {
  const users = useSuspense(UserResource.getList);
  return (
    <div>
      {users.map(user => (
        <section key={user.pk()}>
          <h4>{user.name}&apos;s tasks</h4>
          <TodoList todos={user.todos} userId={user.id} />
        </section>
      ))}
    </div>
  );
}
render(<UserList />);
🔴 Live Preview
Store

Live updates

Keep remote changes in sync with useLive().

Polling, SSE and Websocket or support a custom protocol with middlewares

Editor
import { Entity, RestEndpoint } from '@data-client/rest';

export class Ticker extends Entity {
  product_id = '';
  trade_id = 0;
  price = 0;
  size = '0';
  time = Temporal.Instant.fromEpochSeconds(0);
  bid = '0';
  ask = '0';
  volume = '';

  pk(): string {
    return this.product_id;
  }

  static key = 'Ticker';

  static schema = {
    price: Number,
    time: Temporal.Instant.from,
  };
}

export const getTicker = new RestEndpoint({
  urlPrefix: 'https://api.exchange.coinbase.com',
  path: '/products/:productId/ticker',
  schema: Ticker,
  // in miliseconds
  pollFrequency: 2000,
  process(value, { productId }) {
    value.product_id = productId;
    return value;
  },
});
import { getTicker } from './resources';

export default function AssetPrice({ symbol }: Props) {
  const productId = `${symbol}-USD`;
  const ticker = useLive(getTicker, { productId });
  return (
    <tr>
      <th>{symbol}</th>
      <td>
        <Formatted value={ticker.price} formatter="currency" />
      </td>
    </tr>
  );
}

interface Props {
  symbol: string;
}
import AssetPrice from './AssetPrice';

function AssetList() {
  return (
    <div style={{ display: 'flex', justifyContent: 'center' }}>
      <table>
        <tbody>
          {assets.map(symbol => (
            <AssetPrice key={symbol} symbol={symbol} />
          ))}
        </tbody>
      </table>
    </div>
  );
}
const assets = ['BTC', 'ETH', 'DOGE'];
render(<AssetList />);
🔴 Live Preview
Store
Data IntegrityData Integrity

Data Integrity

Strong inferred types; single source of truth that is referentially stable ensures consistency; asynchronous invariants make it easy to avoid race conditions

Performance

Normalized cache means data is often ready before it is even needed. Automatic request deduplication means less data to send over the network.

Composition over configuration

Declare what you need where you need it. Share data definitions across platforms, components, protocols, and behaviors.

Incremental Adoption

Get started fast with one line data definition and one line data binding. Then add TypeScript, normalized cache with Schemas, optimistic updates and more.