Simple state manager using react and immer.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react' | |
import { Patch, produceWithPatches } from 'immer' | |
export interface StateManager<T = {}> { | |
handle<P>(signal: Signal<P>, callback: (state: T, event: P) => void): () => void | |
getState(): T | |
onStateChanged: Signal<{ state: T, changes: Patch[] }> | |
} | |
export function createStateManager<T>(initialState: T) { | |
let prevState: T | |
let state: T = initialState | |
let changesThisTick: Patch[] = [] | |
const manager = { | |
handle<P>(signal: Signal<P>, handler: (store: T, event: P) => void) { | |
const unsubscribe = signal.subscribe((event) => { | |
const [nextState, changes] = produceWithPatches(state, draftState => { | |
handler(draftState as T, event) | |
}) | |
if (changes.length > 0) { | |
prevState = state | |
state = nextState as T | |
changesThisTick.push(...changes) | |
scheduleChangeEvent() | |
} | |
}) | |
return unsubscribe | |
}, | |
getState() { return state }, | |
getPrevState() { return prevState }, | |
onStateChanged: createSignal<{ state: T, changes: Patch[] }>("State Changed") | |
} as StateManager<T> | |
let timerId: any = null | |
const scheduleChangeEvent = () => { | |
if (timerId != null) return | |
timerId = setTimeout(() => { | |
timerId = null | |
if (changesThisTick.length > 0) { | |
manager.onStateChanged({ state, changes: changesThisTick }) | |
changesThisTick.length = 0 | |
} | |
}, 0) | |
} | |
return manager | |
} | |
const defaultSelector = (store: any) => store | |
export function createFragmentChangedSignal<T, R = T>(store: StateManager<T>, selector: (store: T) => R = defaultSelector): Signal<R> & { stop(): void } { | |
const signal = createSignal<R>("State Fragment Changed") | |
let currentFrag = selector(store.getState()) | |
const stop = handleSignalEvent(store.onStateChanged, (event) => { | |
let newFrag = selector(event.state) | |
if (newFrag !== currentFrag) { | |
currentFrag = newFrag | |
signal(currentFrag) | |
} | |
}) | |
Object.assign(signal, { stop }) | |
return signal as any | |
} | |
interface StateComparator<T> { | |
(updated: T, previous: T): boolean | |
} | |
function defaultComparator<T>(updated: T, previous: T) { | |
return previous !== updated | |
} | |
export function useStateManager<T, R = T>(store: StateManager<T>, selector: (store: T) => R = defaultSelector, comparator: StateComparator<R> = defaultComparator): R { | |
const [state, setState] = React.useState(() => selector(store.getState())) | |
React.useEffect(() => { | |
return store.onStateChanged.subscribe((event) => { | |
const newState = selector(event.state) | |
if (comparator(newState, state)) { | |
setState(newState) | |
} | |
}) | |
}, [state, selector, setState, store.onStateChanged, comparator]) | |
return state | |
} | |
export interface Signal<P> { | |
(params: P): void | |
id: string | |
forward(params: P): (e?: any) => void | |
subscribe(handler: (event: P) => void): () => void | |
unsubscribe(handler?: (event: P) => void): void | |
} | |
interface SignalHandler<P> { | |
(params: P): void | |
} | |
export function createSignal<P>(name?: string): Signal<P> { | |
const handlers: SignalHandler<P>[] = [] | |
const id = uid() + `(${name ?? 'Anonymous'})` | |
function Signal(params: P) { | |
console.debug('(event)', id, "params:", params) | |
handlers.forEach(h => h(params)) | |
} | |
Signal.id = id | |
Signal.forward = function (params: P) { | |
return ($event?: any) => { | |
$event?.preventDefault?.() | |
Signal({ ...params, $event }) | |
} | |
} | |
Signal.subscribe = function (handler: (event: P) => void) { | |
handlers.push(handler) | |
return () => Signal.unsubscribe(handler) | |
} | |
Signal.unsubscribe = function (handler?: (event: P) => void) { | |
const idx = handlers.findIndex(h => h === handler) | |
if (idx >= 0) { | |
handlers.splice(idx, 1) | |
} | |
} | |
return Signal as Signal<P> | |
} | |
export function handleSignalEvent<E>(signal: Signal<E>, callback: (event: E) => void) { | |
return signal.subscribe(callback) | |
} | |
export function useSignalEvent<E>(signal: Signal<E>, callback: (event: E) => void) { | |
React.useEffect(() => { | |
return signal.subscribe(callback) | |
}, [signal, callback]) | |
} | |
let _prevUid = 0 | |
export function uid(radix: number = 36): string { | |
let now = (new Date()).getTime() | |
while (now <= _prevUid) { | |
now += 1 | |
} | |
_prevUid = now | |
return _prevUid.toString(radix) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { memo, useCallback } from "react"; | |
import { addTodos, editTodo, removeTodo, useTodo, useTodos } from "./TodoStore"; | |
export const TodoList = memo(function TodoList(props: any) { | |
const todos = useTodos() | |
if (!todos) return null | |
return ( | |
<div> | |
{todos.map(todo => ( | |
<Todo key={todo.id} id={todo.id} /> | |
))} | |
<hr /> | |
<button onClick={addTodos.forward({})}>Add Todos</button> | |
</div> | |
) | |
}) | |
export default TodoList | |
const Todo = memo(function Todo(props: { id: string }) { | |
const todo = useTodo(props.id) | |
const handleEdit = useCallback(() => { | |
editTodo({ id: props.id, todo: { name: `${todo?.name}+` } }) | |
}, [props.id, todo?.name]) | |
if (!todo) return null | |
return ( | |
<div style={{ display: 'flex' }}> | |
<div style={{ width: 300 }}>{todo.name} </div> | |
<button onClick={handleEdit}>+</button> | |
<button onClick={removeTodo.forward({ id: todo.id })}>X</button> | |
</div> | |
) | |
}) | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useCallback } from "react" | |
import { createSignal, createStateManager, handleSignalEvent, uid, useStateManager } from "../lib/StateManager" | |
export interface ITodo { id: string, name: string, done: boolean } | |
export const todoMgr = createStateManager({ todos: [] as ITodo[] }) | |
export type TodoMgrState = ReturnType<typeof todoMgr['getState']> | |
export const addTodo = createSignal<{ name: string, id?: string }>("Add Todo") | |
todoMgr.handle(addTodo, (state, { name, id = uid() }) => { | |
state.todos.push({ id, name, done: false }) | |
}) | |
export const editTodo = createSignal<{ id: string, todo: Partial<ITodo> }>("Edit Todo") | |
todoMgr.handle(editTodo, (state, { id, todo }) => { | |
const idx = state.todos.findIndex(t => t.id === id) | |
if (idx >= 0) { | |
Object.assign(state.todos[idx], todo) | |
} | |
else { | |
console.warn("Todo not found for id:", id) | |
} | |
}) | |
export const removeTodo = createSignal<{ id: string }>("Remove Todo") | |
todoMgr.handle(removeTodo, (state, { id }) => { | |
const idx = state.todos.findIndex(t => t.id === id) | |
if (idx >= 0) { | |
state.todos.splice(idx, 1) | |
} | |
}) | |
export const addTodos = createSignal<{}>("Add multiple Todos") | |
handleSignalEvent(addTodos, (event) => { | |
console.log("Calling sub signals...") | |
addTodo({ name: "First Task" }) | |
addTodo({ name: "Second Task" }) | |
addTodo({ name: "Third Task" }) | |
}) | |
const selectTodos = (mgr: TodoMgrState) => mgr.todos | |
const dirtyWhenLengthChanges = (a: ITodo[], b: ITodo[]) => a.length !== b.length | |
export function useTodos() { | |
return useStateManager(todoMgr, selectTodos, dirtyWhenLengthChanges) | |
} | |
const selectTodoFromList = (id: string, mgr: TodoMgrState) => mgr.todos.find(t => t.id === id) | |
export function useTodo(id: string) { | |
const extractTodo = useCallback((mgr: TodoMgrState) => selectTodoFromList(id, mgr), [id]) | |
return useStateManager(todoMgr, extractTodo) | |
} | |
//@ts-ignore | |
window['todoMgr'] = todoMgr |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment