diff --git a/README.md b/README.md index 647b5c71..c812c8bb 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ I highly recommend to add a bounty to the issue that you're waiting for to incre - [Action Creators 🌟](#action-creators-) - [Reducers](#reducers) - [State with Type-level Immutability](#state-with-type-level-immutability) + - [Modelling async data with ADT](#modelling-async-data-with-adt) - [Typing reducer](#typing-reducer) - [Typing reducer with `typesafe-actions`](#typing-reducer-with-typesafe-actions) - [Testing reducer](#testing-reducer) @@ -1491,6 +1492,94 @@ state.containerObject.numbers.push(1); // TS Error: cannot use mutator methods [⇧ back to top](#table-of-contents) +### Modelling async data with ADT + +Async reducer state is often written as several nullable fields and flags: + +```ts +type TodosState = Readonly<{ + isLoading: boolean, + error: string | null, + todos: ReadonlyArray | null, +}>; +``` + +That shape accepts impossible combinations, for example `isLoading: true` with +both `error` and `todos` populated. A discriminated union makes every remote +data case explicit and keeps those combinations out of the state type. + +```ts +type RemoteData = + | { readonly tag: 'notAsked' } + | { readonly tag: 'loading' } + | { readonly tag: 'failure'; readonly error: E } + | { readonly tag: 'success'; readonly data: T }; + +type TodosState = Readonly<{ + todos: RemoteData>, +}>; + +const initialState: TodosState = { + todos: { tag: 'notAsked' }, +}; +``` + +Reducer transitions then replace the whole remote data value instead of +coordinating separate flags: + +```ts +type TodosAction = + | { readonly type: 'FETCH_TODOS_REQUEST' } + | { readonly type: 'FETCH_TODOS_SUCCESS'; readonly payload: ReadonlyArray } + | { readonly type: 'FETCH_TODOS_FAILURE'; readonly payload: string } + | { readonly type: 'FETCH_TODOS_RESET' }; + +const todosReducer = ( + state: TodosState = initialState, + action: TodosAction, +): TodosState => { + switch (action.type) { + case 'FETCH_TODOS_REQUEST': + return { ...state, todos: { tag: 'loading' } }; + case 'FETCH_TODOS_SUCCESS': + return { ...state, todos: { tag: 'success', data: action.payload } }; + case 'FETCH_TODOS_FAILURE': + return { ...state, todos: { tag: 'failure', error: action.payload } }; + case 'FETCH_TODOS_RESET': + return { ...state, todos: { tag: 'notAsked' } }; + default: + return state; + } +}; +``` + +Connected components can render each case with the same discriminant. The +`assertNever` branch turns a missing case into a type error when the union is +extended later. + +```tsx +const TodoListView: React.FC<{ todos: RemoteData> }> = ({ todos }) => { + switch (todos.tag) { + case 'notAsked': + return Choose a filter to load todos.; + case 'loading': + return Loading todos...; + case 'failure': + return {todos.error}; + case 'success': + return ; + default: + return assertNever(todos); + } +}; + +function assertNever(value: never): never { + throw new Error(`Unhandled remote data case: ${JSON.stringify(value)}`); +} +``` + +[⇧ back to top](#table-of-contents) + ### Typing reducer > to understand following section make sure to learn about [Type Inference](https://www.typescriptlang.org/docs/handbook/type-inference.html), [Control flow analysis](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#control-flow-based-type-analysis) and [Tagged union types](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#tagged-union-types) diff --git a/README_SOURCE.md b/README_SOURCE.md index 358fcc35..662d4acb 100644 --- a/README_SOURCE.md +++ b/README_SOURCE.md @@ -125,6 +125,7 @@ I highly recommend to add a bounty to the issue that you're waiting for to incre - [Action Creators 🌟](#action-creators-) - [Reducers](#reducers) - [State with Type-level Immutability](#state-with-type-level-immutability) + - [Modelling async data with ADT](#modelling-async-data-with-adt) - [Typing reducer](#typing-reducer) - [Typing reducer with `typesafe-actions`](#typing-reducer-with-typesafe-actions) - [Testing reducer](#testing-reducer) @@ -612,6 +613,94 @@ state.containerObject.numbers.push(1); // TS Error: cannot use mutator methods [⇧ back to top](#table-of-contents) +### Modelling async data with ADT + +Async reducer state is often written as several nullable fields and flags: + +```ts +type TodosState = Readonly<{ + isLoading: boolean, + error: string | null, + todos: ReadonlyArray | null, +}>; +``` + +That shape accepts impossible combinations, for example `isLoading: true` with +both `error` and `todos` populated. A discriminated union makes every remote +data case explicit and keeps those combinations out of the state type. + +```ts +type RemoteData = + | { readonly tag: 'notAsked' } + | { readonly tag: 'loading' } + | { readonly tag: 'failure'; readonly error: E } + | { readonly tag: 'success'; readonly data: T }; + +type TodosState = Readonly<{ + todos: RemoteData>, +}>; + +const initialState: TodosState = { + todos: { tag: 'notAsked' }, +}; +``` + +Reducer transitions then replace the whole remote data value instead of +coordinating separate flags: + +```ts +type TodosAction = + | { readonly type: 'FETCH_TODOS_REQUEST' } + | { readonly type: 'FETCH_TODOS_SUCCESS'; readonly payload: ReadonlyArray } + | { readonly type: 'FETCH_TODOS_FAILURE'; readonly payload: string } + | { readonly type: 'FETCH_TODOS_RESET' }; + +const todosReducer = ( + state: TodosState = initialState, + action: TodosAction, +): TodosState => { + switch (action.type) { + case 'FETCH_TODOS_REQUEST': + return { ...state, todos: { tag: 'loading' } }; + case 'FETCH_TODOS_SUCCESS': + return { ...state, todos: { tag: 'success', data: action.payload } }; + case 'FETCH_TODOS_FAILURE': + return { ...state, todos: { tag: 'failure', error: action.payload } }; + case 'FETCH_TODOS_RESET': + return { ...state, todos: { tag: 'notAsked' } }; + default: + return state; + } +}; +``` + +Connected components can render each case with the same discriminant. The +`assertNever` branch turns a missing case into a type error when the union is +extended later. + +```tsx +const TodoListView: React.FC<{ todos: RemoteData> }> = ({ todos }) => { + switch (todos.tag) { + case 'notAsked': + return Choose a filter to load todos.; + case 'loading': + return Loading todos...; + case 'failure': + return {todos.error}; + case 'success': + return ; + default: + return assertNever(todos); + } +}; + +function assertNever(value: never): never { + throw new Error(`Unhandled remote data case: ${JSON.stringify(value)}`); +} +``` + +[⇧ back to top](#table-of-contents) + ### Typing reducer > to understand following section make sure to learn about [Type Inference](https://www.typescriptlang.org/docs/handbook/type-inference.html), [Control flow analysis](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#control-flow-based-type-analysis) and [Tagged union types](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#tagged-union-types)