Skip to content

Instantly share code, notes, and snippets.

@mattmccray
Last active May 30, 2021 03:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattmccray/07db9129a82302f2a94de261c1ad9630 to your computer and use it in GitHub Desktop.
Save mattmccray/07db9129a82302f2a94de261c1ad9630 to your computer and use it in GitHub Desktop.
Simple state manager using react and immer.
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)
}
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}&nbsp; &nbsp;</div>
<button onClick={handleEdit}>+</button>
<button onClick={removeTodo.forward({ id: todo.id })}>X</button>
</div>
)
})
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