Skip to main content

Data mutations

Using our Create, Update, and Delete endpoints with Controller.fetch() reactively updates all appropriate components atomically (at the same time).

useController() gives components access to this global setState() on steriods.

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

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

export default function TodoItem({ todo }: { todo: Todo }) {
  const ctrl = useController();
  const handleChange = e =>
    ctrl.fetch(
      TodoResource.partialUpdate,
      { id: todo.id },
      { completed: e.currentTarget.checked },
    );
  const handleDelete = () =>
    ctrl.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 { v4 as uuid } from 'uuid';
import { useController } from '@data-client/react';
import { TodoResource } from './TodoResource';

export default function CreateTodo({ userId }: { userId: number }) {
  const ctrl = useController();
  const handleKeyDown = async e => {
    if (e.key === 'Enter') {
      ctrl.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 { useSuspense } from '@data-client/react';
import { TodoResource } from './TodoResource';
import TodoItem from './TodoItem';
import CreateTodo from './CreateTodo';

function TodoList() {
  const userId = 1;
  const todos = useSuspense(TodoResource.getList, { userId });
  return (
    <div>
      {todos.map(todo => (
        <TodoItem key={todo.pk()} todo={todo} />
      ))}
      <CreateTodo userId={userId} />
    </div>
  );
}
render(<TodoList />);
🔴 Live Preview
Store

Rather than triggering invalidation cascades or using manually written update functions, RDC reactively updates appropriate components using the fetch response.

Optimistic mutations based on previous state

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

export class Post extends Entity {
  id = 0;
  userId = 0;
  title = '';
  body = '';
  votes = 0;

  pk() {
    return this.id?.toString();
  }
  static key = 'Post';

  get img() {
    return `//placekitten.com/96/72?image=${this.id % 16}`;
  }
}
import { RestEndpoint, createResource } from '@data-client/rest';
import { AbortOptimistic } from '@data-client/rest';
import { Post } from './Post';

export { Post };

export const PostResource = createResource({
  path: '/posts/:id',
  schema: Post,
}).extend(Base => ({
  vote: new RestEndpoint({
    path: '/posts/:id/vote',
    method: 'POST',
    body: undefined,
    schema: Post,
    getOptimisticResponse(snapshot, { id }) {
      const { data } = snapshot.getResponse(Base.get, { id });
      if (!data) throw new AbortOptimistic();
      return {
        id,
        votes: data.votes + 1,
      };
    },
  }),
}));
import { useController } from '@data-client/react';
import { PostResource, type Post } from './PostResource';

export default function PostItem({ post }: { post: Post }) {
  const ctrl = useController();
  const handleVote = () => {
    ctrl.fetch(PostResource.vote, { id: post.id });
  };
  return (
    <div>
      <div className="voteBlock">
        <small className="vote">
          <button className="up" onClick={handleVote}>
            &nbsp;
          </button>
          {post.votes}
        </small>
        <img src={post.img} width="70" height="52" />
      </div>
      <div>
        <h4>{post.title}</h4>
        <p>{post.body}</p>
      </div>
    </div>
  );
}
import { Query, schema } from '@data-client/rest';
import { Post } from './PostResource';

const queryTotalVotes = new Query(
  new schema.All(Post),
  (posts, { userId } = {}) => {
    if (userId !== undefined)
      posts = posts.filter(post => post.userId === userId);
    return posts.reduce((total, post) => total + post.votes, 0);
  },
);

export default function TotalVotes({ userId }: { userId: number }) {
  const totalVotes = useCache(queryTotalVotes, { userId });
  return (
    <center>
      <small>{totalVotes} votes total</small>
    </center>
  );
}
import { useSuspense } from '@data-client/react';
import { PostResource } from './PostResource';
import PostItem from './PostItem';
import TotalVotes from './TotalVotes';

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

getOptimisticResponse is just like setState with an updater function. Snapshot provides typesafe access to the previous store value, which we use to return the expected fetch response.

Reactive Data Client ensures data integrity against any possible networking failure or race condition, so don't worry about network failures, multiple mutation calls editing the same data, or other common problems in asynchronous programming.

Tracking mutation loading

useLoading() enhances async functions by tracking their loading and error states.

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

export class Post extends Entity {
  id = 0;
  userId = 0;
  title = '';
  body = '';
  votes = 0;

  pk() {
    return this.id?.toString();
  }
  static key = 'Post';

  get img() {
    return `//placekitten.com/96/72?image=${this.id % 16}`;
  }
}
export const PostResource = createResource({
  path: '/posts/:id',
  schema: Post,
});
import { useSuspense } from '@data-client/react';
import { PostResource } from './PostResource';

export default function PostDetail({ id }) {
  const post = useSuspense(PostResource.get, { id });
  return (
    <div>
      <div className="voteBlock">
        <img src={post.img} width="70" height="52" />
      </div>
      <div>
        <h4>{post.title}</h4>
        <p>{post.body}</p>
      </div>
    </div>
  );
}
export default function PostForm({ onSubmit, loading, error }) {
  const handleSubmit = e => {
    e.preventDefault();
    const data = new FormData(e.target);
    onSubmit(data);
  };
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Title:
        <br />
        <input type="text" name="title" defaultValue="My New Post"
          required />
      </label>
      <br />
      <label>
        Body:
        <br />
        <textarea name="body" rows={12} required>
          After clicking 'save', the button will be disabled until
          the POST is completed. Upon completion the newly created
          post is displayed immediately as Reactive Data Client is
          able to use the fetch response to populate the store.
        </textarea>
      </label>
      {error ? (
        <div className="alert alert--danger">{error.message}</div>
      ) : null}
      <div>
        <button type="submit" disabled={loading}>
          {loading ? 'saving...' : 'Save'}
        </button>
      </div>
    </form>
  );
}
import { useController } from '@data-client/react';
import { useLoading } from '@data-client/hooks';
import { PostResource } from './PostResource';
import PostForm from './PostForm';

export default function PostCreate({ navigateToPost }) {
  const ctrl = useController();
  const [handleSubmit, loading, error] = useLoading(
    async data => {
      const post = await ctrl.fetch(PostResource.getList.push, data);
      navigateToPost(post.id);
    },
    [ctrl],
  );
  return <PostForm onSubmit={handleSubmit} loading={loading}
    error={error} />;
}
import PostCreate from './PostCreate';
import PostDetail from './PostDetail';

function Navigation() {
  const [id, setId] = React.useState<undefined | number>(undefined);
  if (id) {
    return (
      <div>
        <PostDetail id={id} />
        <center>
          <button onClick={() => setId(undefined)}>New Post</button>
        </center>
      </div>
    );
  }
  return <PostCreate navigateToPost={setId} />;
}
render(<Navigation />);
🔴 Live Preview
Store