Share server data and
update it instantly
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 />);
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 />);
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, {
id: randomId(),
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>
);
}
function randomId() {
return Number.parseInt(uuid().slice(0, 8), 16);
}
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}'s tasks</h4>
<TodoList todos={user.todos} userId={user.id} />
</section>
))}
</div>
);
}
render(<UserList />);
import { Entity, schema, RestEndpoint } from '@data-client/rest';
export class ExchangeRates extends Entity {
currency = 'USD';
rates: Record<string, number> = {};
pk(): string {
return this.currency;
}
static schema = {
rates: new schema.Values(FloatSerializer),
};
}
export const getExchangeRates = new RestEndpoint({
urlPrefix: 'https://www.coinbase.com/api/v2',
path: '/exchange-rates',
searchParams: {} as { currency: string },
schema: { data: ExchangeRates },
pollFrequency: 15000,
});
export default function AssetPrice({ symbol, value }: Props) {
return (
<tr key={symbol}>
<th>{symbol}</th>
<td>
<Formatted value={value} formatter="currency" />
</td>
</tr>
);
}
interface Props {
symbol: string;
value: number;
}
import AssetPrice from './AssetPrice';
import { getExchangeRates } from './resources';
function AssetList() {
const { data: price } = useLive(getExchangeRates, {
currency: 'USD',
});
return (
<center>
<table>
<tbody>
{assets.map(symbol => (
<AssetPrice
key={symbol}
symbol={symbol}
value={1 / price.rates[symbol]}
/>
))}
</tbody>
</table>
<small>Updates every 15 seconds</small>
</center>
);
}
const assets = ['BTC', 'ETH', 'DOGE'];
render(<AssetList />);
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.