|
| 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