Skip to content

Commit 42064d9

Browse files
committed
docs: Add blog announcing experimental pkg and usecontroller
1 parent e2e4841 commit 42064d9

File tree

2 files changed

+397
-0
lines changed

2 files changed

+397
-0
lines changed
Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
---
2+
title: Experimental useController()
3+
authors: [ntucker]
4+
tags: [releases, rest-hooks, packages]
5+
---
6+
7+
[@rest-hooks/experimental](https://www.npmjs.com/package/@rest-hooks/experimental) is a new
8+
package that allows us to quickly iterate on new designs by using them in production, which provides
9+
feedback in ways not possible at design and testing phase.
10+
11+
This package is **not** api stable. However, it is tested with the same rigor we expect with Rest Hooks
12+
as we use it in production. It is recommend to use this for providing feedback or playing with designs,
13+
unless you are willing to put in extra work to make migrations. Detailed migration guides will only be
14+
provided upon upstreaming to the mainline packages.
15+
16+
Today this package comes with two new features:
17+
18+
**[useController()](#usecontroller)**
19+
20+
```ts
21+
const { fetch, invalidate, resetEntireStore } = useController();
22+
fetch(MyResource.detail(), { id });
23+
```
24+
25+
**[Resource.list().paginated()](#resourcelistpaginated)**
26+
27+
```ts
28+
class NewsResource extends Resource {
29+
static listPage<T extends typeof NewsResource>(this: T) {
30+
return this.list().paginated(({ cursor, ...rest }) => [rest]);
31+
}
32+
}
33+
```
34+
35+
<!--truncate-->
36+
37+
## useController()
38+
39+
### Usage
40+
41+
```tsx
42+
import { useController } from '@rest-hooks/experimental';
43+
44+
function MyComponent({ id }) {
45+
const { fetch, invalidate, resetEntireStore } = useController();
46+
47+
const handleRefresh = useCallback(
48+
async e => {
49+
await fetch(MyResource.detail(), { id });
50+
},
51+
[fetch, id],
52+
);
53+
54+
const handleSuspend = useCallback(
55+
async e => {
56+
await invalidate(MyResource.detail(), { id });
57+
},
58+
[invalidate, id],
59+
);
60+
61+
const handleLogout = useCallback(
62+
async e => {
63+
resetEntireStore();
64+
},
65+
[resetEntireStore],
66+
);
67+
}
68+
```
69+
70+
[PR](https://github.com/coinbase/rest-hooks/pull/1048)
71+
72+
### Motivation
73+
74+
- Consolidate, simplify hooks
75+
- Consistent interface between managers and hooks
76+
- Global referential equality available everywhere (managers and updaters)
77+
- Simplify and consolidate TTL and error concepts
78+
- Less code in hooks = less work on rendering leaf nodes
79+
- Icing on cake: ez migration to EndpointInterface and flexible args support for hooks
80+
- Future breaking changes can allow ez migration with version strings sent to `useController({version: 'v2'})`
81+
82+
83+
### One hook, many endpoints
84+
85+
The rules of hooks are very restrictive, so the less hooks you have to call, the more flexible. This also benefits render performance. In many cases you might want to fetch many different endpoints. What's worse is if you don't know which endpoints you might want to fetch upfront. With old design you'd have to hook up every _possible_ one. This really destroys fetch-as-render pattern, as you want to be able to prefetch based on possible routes.
86+
87+
#### Before
88+
89+
```tsx
90+
const createUser = useFetcher(User.create());
91+
const refreshUsers = useFetcher(User.list());
92+
93+
return (
94+
<form onSubmit={() => createUser({}, userPayload)}>
95+
<button onClick={() => refreshUsers({})}>Refresh list</button>
96+
</form>
97+
);
98+
```
99+
100+
#### After
101+
102+
```tsx
103+
const { fetch } = useController();
104+
105+
return (
106+
<form onSubmit={() => fetch(User.create(), {}, userPayload)}>
107+
<button onClick={() => fetch(User.list(), {})}>Refresh list</button>
108+
</form>
109+
);
110+
```
111+
112+
### Completely flexible, variable arguments
113+
114+
The concept of params + body for arguments was introduced to try to provide the most flexible approach in a world where type enforcement wasn't that flexible. With TypeScript 4's variadic tuples, it's now possible to strongly type arbitrary arguments to a function in a _generic_ way. Furthermore, stumbling upon package.json's typeVersions, rest hooks can now publish multiple type versions to be compatible with different versions of typescript. This allows us to eagerly adopt TypeScript 4 features, while providing a usable TypeScript 3 experience.
115+
116+
Some common annoyances with the current parameter limitations are single-variable arguments like detail endpoints with an id, as well as no-argument case like a list endpoint or create endpoint.
117+
118+
```tsx
119+
const { fetch } = useController();
120+
121+
return (
122+
<form onSubmit={() => fetch(User.create(), userPayload)}>
123+
<button onClick={() => fetch(User.list())}>Refresh list</button>
124+
</form>
125+
);
126+
```
127+
128+
We'll also eventually bring this to the 'read' hooks like so:
129+
130+
```tsx
131+
// notice username is just a string, rather than object
132+
const user = useResource(User.detail(), username);
133+
// here we don't need arguments
134+
const posts = useResource(Post.list());
135+
// but list() has it being optional, which means this also works:
136+
const goodPosts = useResource(Post.list(), { good: true });
137+
// postId is a number in this case
138+
const thePost = useResource(Post.detail(), postId);
139+
```
140+
141+
### fetch: Endpoint definition - new updater
142+
143+
By normalizing [Entities](https://resthooks.io/docs/api/Entity), Rest Hooks guarantees data integrity and consistency even down to the referential equality level. However, there are still some cases where side effects result in changes to the actual results themselves. The most common reason for this is creation of new entities. While 'creation' is almost universally the cause for this (as deletion is handled more simply by delete schemas), the structure of data and where created elements go is not universal.
144+
145+
#### Before
146+
147+
Previously this was enabled by an optional third argument to the fetch [UpdateParams](https://resthooks.io/docs/api/useFetcher#updateparams-destendpoint-destparams-updatefunction) enabling programmatic changes that are also strictly type enforced to ensure the data integrity of the Rest Hooks store.
148+
149+
```typescript
150+
const createArticle = useFetcher(ArticleResource.create());
151+
152+
createArticle({}, { id: 1 }, [
153+
[
154+
ArticleResource.list(),
155+
{},
156+
(newArticleID: string, articleIDs: string[] | undefined) => [
157+
...(articleIDs || []),
158+
newArticleID,
159+
],
160+
],
161+
]);
162+
```
163+
164+
While simple, this design had several shortcomings
165+
166+
- Only operates on the normalized results, often arrays of strings
167+
- This is non-intuitive as this doesn't relate directly to the data's form and requires understanding of internals
168+
- Code is confusing with two ordered args and necessary default handling
169+
- Lack of access to entities means sorting is not possible
170+
- Can only update top level results, which means lists nested inside entities cannot be updated
171+
- Is provided as an argument to the fetch rather than endpoint
172+
- Makes variable arguments impossible, and hard to reason about
173+
- Makes pattern reuse still require explicit wiring
174+
- Was thought to be more flexible than in 'fetchshape', as it has access to local variables in its event handler. However, Endpoints's can easily use `.extend()` to contextually override so this feature is moot.
175+
- Encourages antipatterns like writing hooks for specific endpoints
176+
177+
#### After
178+
179+
- Operate on the actual denormalized form - that is the same shape that is consumsed with a useResource()
180+
- Move to Endpoint
181+
- Take the denormalized response as arg to first function
182+
- builder pattern to make updater definition easy
183+
- typeahead
184+
- strong type enforcement
185+
- much more readable than a size 3 tuple
186+
187+
Simplest case:
188+
189+
```typescript
190+
type UserList = Denormalized<typeof userList['schema']>;
191+
192+
const createUser = new Endpoint(postToUserFunction, {
193+
schema: User,
194+
update: (newUser: Denormalize<S>) => [
195+
userList.bind().updater((users: UserList = []) => [newUser, ...users]),
196+
],
197+
});
198+
```
199+
200+
More updates:
201+
202+
<details open><summary><b>Component.tsx</b></summary>
203+
204+
```typescript
205+
const allusers = useResource(userList);
206+
const adminUsers = useResource(userList, { admin: true });
207+
const sortedUsers = useResource(userList, { sortBy: 'createdAt' });
208+
```
209+
210+
</details>
211+
212+
The endpoint below ensures the new user shows up immediately in the usages above.
213+
214+
<details open><summary><b>userEndpoint.ts</b></summary>
215+
216+
```typescript
217+
const createUser = new Endpoint(postToUserFunction, {
218+
schema: User,
219+
update: (newUser: Denormalize<S>) => {
220+
const updates = [
221+
userList.bind().updater((users = []) => [newUser, ...users]),
222+
userList.bind({ sortBy: 'createdAt' }).updater((users = [], { sortBy }) => {
223+
const ret = [createdUser, ...users];
224+
ret.sortBy(sortBy);
225+
return ret;
226+
},
227+
];
228+
if (newUser.isAdmin) {
229+
updates.push(userList.bind({ admin: true }).updater((users = []) => [newUser, ...users]));
230+
}
231+
return updates;
232+
},
233+
});
234+
```
235+
236+
</details>
237+
238+
#### Extracting patterns
239+
240+
In case more than one other endpoint might result in updating our list endpoint, we can centralize the logic of how that should work in our updated endpoint.
241+
242+
```typescript
243+
const userList = new Endpoint(getUsers, {
244+
schema: User[],
245+
addUserUpdater: (this: Endpoint, newUser: User) => this.updater((users = []) => [newUser, ...users]),
246+
});
247+
```
248+
249+
```typescript
250+
const createUser = new Endpoint(postToUserFunction, {
251+
schema: User,
252+
update: (newUser: Denormalize<S>) => [
253+
userList.bind({ admin: true }).addUserUpate(newUser),
254+
userList.bind({}).addUserUpate(newUser),
255+
],
256+
});
257+
```
258+
259+
<details open><summary><b>Alternate Ideas - The programmatic approach</b></summary>
260+
261+
#### The no guarantees
262+
263+
```typescript
264+
const createUser = new Endpoint(postToUserFunction, {
265+
schema: User,
266+
update: (newUser: Denormalize<S>, state: State<unknown>) => {
267+
return {
268+
...state,
269+
results: {
270+
...state.results,
271+
[userList.key({ admin: true })]: [newUser.pk(), ...state.results[userList.key({ admin: true })]],
272+
[userList.key({ })]: [newUser.pk(), ...state.results[userList.key({ })]],
273+
}
274+
}
275+
}
276+
}
277+
```
278+
279+
#### Store Adapter
280+
281+
```typescript
282+
const createUser = new Endpoint(postToUserFunction, {
283+
schema: User,
284+
update: (newUser: Denormalize<S>, store: Store) => {
285+
const prependUser = (users = []) => [newUser, ...users]
286+
if (newUser.isAdmin) {
287+
store = store.set(
288+
userList.bind({admin: true}),
289+
prependUser
290+
);
291+
}
292+
store = store.set(
293+
userList.bind({}),
294+
prependUser
295+
);
296+
return store;
297+
}
298+
}
299+
```
300+
301+
```typescript
302+
const createUser = new Endpoint(postToUserFunction, {
303+
schema: User,
304+
update: (newUser: Denormalize<S>, store: Store) => {
305+
// this actually goes through every current result based on this endpoint
306+
// however it does not update extremely stale results for performance reasons
307+
store.get(userList).mapItems((key: string, users: User[]) => {
308+
if (!key.includes('admin') || newUser.isAdmin) {
309+
return [newUser, ...users];
310+
}
311+
});
312+
}
313+
}
314+
```
315+
316+
Another idea is to make an updater callback with identical API to [manager middleware](https://resthooks.io/docs/api/Manager#getmiddleware). We would probably want to minimize chaining actions, so some way of consolidating into one action would be preferable. Adding an adapter to raw state might be good for Manager's as well, so designing this interface could be beneficial to optionally improving middleware interfaces.
317+
318+
</details>
319+
320+
### Resolution order
321+
322+
This makes little difference in React 18 since renders are batched; however in React < 18, this means that code after promise resolution will be executed before react renders - allowing actions that need to take place as a result of successful fetch. For example navigating off a deleted page after delete.
323+
324+
```typescript
325+
const handleDelete = useCallback(
326+
async e => {
327+
await fetch(MyResource.delete(), { id });
328+
history.push('/');
329+
},
330+
[fetch, id],
331+
);
332+
```
333+
334+
:::warning
335+
336+
It's now recommended to wrap all fetches in act when testing like so:
337+
338+
```ts
339+
await act(async () => {
340+
await result.current.fetch(ComplexResource.detail(), {
341+
id: '5',
342+
});
343+
});
344+
```
345+
346+
:::
347+
348+
[PR](https://github.com/coinbase/rest-hooks/pull/1046)
349+
350+
## Resource.list().paginated()
351+
352+
### Motivation
353+
<!--
354+
Does this solve a bug? Enable a new use-case? Improve an existing behavior? Concrete examples are helpful here.
355+
-->
356+
Pagination is a common scenario, that would benefit from minimal specification.
357+
358+
### Solution
359+
<!--
360+
What is the solution here from a high level. What are the key technical decisions and why were they made?
361+
-->
362+
363+
By default we rely on finding a list within the schema. The only remaining thing is figuring out how to extract the 'cursor' args to update the main list. Therefore, a function to do just that should be provided by the user like so.
364+
365+
```ts
366+
class NewsResource extends Resource {
367+
static listPage<T extends typeof NewsResource>(this: T) {
368+
return this.list().paginated(({ cursor, ...rest }) => [rest]);
369+
}
370+
}
371+
```
372+
373+
374+
```tsx
375+
import { useResource } from 'rest-hooks';
376+
import NewsResource from 'resources/NewsResource';
377+
378+
function NewsList() {
379+
const { results, cursor } = useResource(NewsResource.list(), {});
380+
const curRef = useRef(cursor);
381+
curRef.current = cursor;
382+
const fetch = useFetcher();
383+
const getNextPage = useCallback(
384+
() => fetch(NewsResource.listPage(), { cursor: curRef.current }),
385+
[]
386+
);
387+
388+
return (
389+
<Pagination onPaginate={getNextPage} nextCursor={cursor}>
390+
<NewsList data={results} />
391+
</Pagination>
392+
);
393+
}
394+
```
395+
396+
[PR](https://github.com/coinbase/rest-hooks/pull/868)

0 commit comments

Comments
 (0)