Type-safe, composable, boilerplateless reducers
npm install --save @betafcc/red
import React, { useReducer } from 'react'
import { render } from 'react-dom'
import { red } from '@betafcc/red'
const inputApp = red
.withState({ input: '' })
.handle({ setInput: (state, input: string) => ({ input }) })
const todoApp = inputApp
.withState({ todos: [] as Array<{ msg: string; done: boolean }> })
.handle({
add: (state, msg: string) => ({ input: '', todos: [...s.todos, { msg, done: false }] }),
complete: (state, id: number) => ({ todos: state.todos.map((e, i) => i !== id ? e : { ...e, done: true }) }),
})
export const TodoApp = () => {
const [{ input, todos }, { setInput, add, complete }] = todoApp.useHook(useReducer)
return <>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={_ => add(input)}>add</button>
{todos.map((e, id) => <li key={id} style={e.done ? { textDecoration: 'line-through' } : {}}>
{e.msg}<button onClick={_ => complete(id)}>done</button>
</li>)}
</>
}
render(<TodoApp />, document.getElementById('root'))
If every action has this shape:
type ActionType<K extends string, P extends Array<unknown>> = {
type: K
payload: P
}
We can automatically provide action creators and strong-typed reducer from simple handlers definitions:
const app = red
.withState({
input: '',
todo: [] as Array<{msg: string, id: number, done: boolean}>
})
.handle({
setInput(state, input: string) {
return { input }
},
addTodo(state, msg: string, id: number) {
return { todo: [...state.todo, {msg, id, done: false}] }
},
completeTodo(state, id: number) {
return { todo: todo.map(t => t.id !== id ? t : {...t, done: true}) }
}
})
const {
intial, // the initial state
reducer, // the reducer made by combining the handlers
actions, // the action creators
} = red
The arguments in the handlers define the payload, and their keys define the 'type', the revealed signature is:
import { StateOf, ActionOf } from '@betafcc/red'
type State = StateOf<typeof red>
// { input: string, todo: Array<{msg: string, id: number, done: boolean}> }
type Action = ActionOf<typeof red>
// { type: 'setInput', payload: [string] } | { type: 'addTodo', payload: [string, number] } | { type: 'completeTodo', payload: [number] }
And you also have action creators that matches the signature:
const { addTodo, completeTodo } = app.actions
addTodo('buy milk', 0) // { type: 'addTodo', payload: ['buy milk', 0] }
completeTodo(0) // { type: 'completeTodo', payload: [number] }
If your prefer to define the actions yourself, you can use ActionType
helper and withActions
method:
import { ActionType } from '@betafcc/red'
type Action =
| ActionType<'setInput', [string]>
| ActionType<'addTodo', [string, number]>
| ActionType<'completeTodo', [number]>
const app = red
.withState({ input: '', todo: [] as Array<{msg: string, id: number, done: boolean}>})
.withActions<Action>({ // annotate here
setInput(state, input) { // so arguments will have infered type, no need to annotate here
return {input}
},
addTodo(state, msg, id) {
return {todo: [...state.todo, {msg, id, done: false}]}
},
completeTodo(state, id) {
return {todo: todo.map(t => t.id !== id ? t : {...t, done: true})}
}
})
The simplest way to use is to extract the generated reducer
, initial
and the actions
creators:
const {reducer, initial, actions} = app
const App = () => {
const [state, dispatch] = React.useReducer(reducer, initial)
return <>
{state.count}
<button onClick={_ => dispatch(actions.increment())}>+</button>
</>
}
But you can use red.useHook
for some extra magic:
const App = () => {
// the action creators will become dispatchers
const [state, {increment}] = app.useHook(React.useReducer)
return <>
{state.count}
<button onClick={_ => increment()}>+</button>
</>
}
You can use red.merge
to combine apps together:
const inputApp = red
.withState({input: ''})
.handle({setInput: (s, value: string) => ({input: value})})
const todoApp = red
.withState({todos: [] as Array<{id: number, done: boolean, msg: string}>})
.handle({add: (s, msg: string, id: number) => ({todos: [...s.todos, {id, msg, done: false}]}) })
const app = red.merge(inputApp).merge(todoApp)
// same as
const app = inputApp.merge(todoApp)
// same as
const app = red
.withState({input: ''})
.handle({setInput: (s, value: string) => ({input: value})})
.withState({todos: [] as Array<{id: number, done: boolean, msg: string}>})
.handle({add: (s, msg: string, id: number) => ({todos: [...s.todos, {id, msg, done: false}]}) })
Or you can combine them by namespacing with red.combine
, similar to redux's combineReducers
:
const inputApp = red.withState({input: ''}).handle({setInput: (s, input: string) => ({input})})
const counterApp = red.withState({count: 0}).handle({increment: s => ({count: s.count + 1})})
const app = red.combine({
ui: inputApp,
counter: counterApp
})
// equivalent to:
const app = red.withState({
ui: {input: ''},
counter: {count: 0}
}).handle({
// note that you have to manually namespace the state
setInput: (s, input: string) => ({...s, ui: {input}}),
increment: s => ({...s, counter: {count: s.counter.count + 1}})
})