Skip to content

Instantly share code, notes, and snippets.

@steveruizok
Last active August 20, 2021 13:42
Show Gist options
  • Save steveruizok/f3a407f88a5573d33e8e179089d0edf5 to your computer and use it in GitHub Desktop.
Save steveruizok/f3a407f88a5573d33e8e179089d0edf5 to your computer and use it in GitHub Desktop.

README

This is a state management solution I've been using for apps that require persistance and an undo / redo stack. In an app, you would extend the StateManager class.

interface State {
  count: number
  user: {
    name: string
    age: number
  }
}

class AppState extends StateManager<State> {
  constructor(initialState: State) {
    super(initialState)
  }
}

const appState = new AppState({
  count: 0,
  user: {
    name: "Steve",
    age: 93
  }
})

SetState and PatchState

The base class provides methods for changign the state. For changes that should produce a new state without changing the undo/redo stack, call patchState with a "patch", or deep partial of the state. (Note that arrays are replaced, not merged.)

  getOlder() {
    const state = this.getState()
    this.patchState({
      user: {
       age: state.user.age + 1
      }
    })
  }

For changes that are intended to be part of the undo/redo stack, call setState with a "command" made of two "patches": an after patch that will make the change and a before patch that would reverse the change. (You can also provide an id to help with debugging.)

  getOlder() {
    const state = this.getState()
    
    this.setState({
      before: {
        user: {
          age: state.user.age
        }
      },
      after: {
        user: {
          age: state.user.age + 1
        }
      }
    })
  }

Current and Previous

The current state (either the initial state or the one produced by the last call to setState or patchState is available at this.current. Calling either setState or patchState will change the value of this.current.

Calling setState will also update the value of this.previous. Calling patchState will not update this.previous. In this way, this.previous allows for patterns a user must be able to undo back to an original point while ignoring all points in between.

  startScrubbing() {
    const { current } = this
    
    this.setState({
      before: {
        count: current.count
      },
      after: {
        count: current.count + 1
      }
    })
  }
  
  scrub(delta: number) {
    const { current } = this
    
    this.patchState({
      count: current.count + delta
    })
  }
  
  stopScrubbing() {
    const { previous, current } = this
    
    this.setState({
      before: {
        count: previous.count
      },
      after: {
        count: current.count
      }
    })
  }

In the example above, imagine a user began a scrubbing session when the count is 0, then scrubbed the number from 1 to 2 to 3, and then stopped scrubbing. Calling undo would change the count from 3 to 0. Calling redo would change the count from 0 to 3.

Usage in React

The StateManager class keeps its state in a zustand store. A React component can select from / subscribe to this store.

// ...

const appState = new AppState({
  count: 0,
  user: {
    name: "Steve",
    age: 93
  }
})

function App() {
  const { count } = appState.useAppState(state => state.count)
  
  <div>
    <h1>{count}</h1>
    <button onClick={() => appState.increment()}>Increment</button>
    <button onClick={() => appState.undo()}>Undo</button>
    <button onClick={() => appState.redo()}>Redo</button>
  </div>
}
import merge from 'deepmerge'
import * as idb from 'idb-keyval'
import createReact, { UseStore } from 'zustand'
import createVanilla, { StoreApi } from 'zustand/vanilla'
// Types
export type Patch<T> = Partial<{ [P in keyof T]: Patch<T[P]> }>
export type Command<T> = {
id?: string
before: Patch<T>
after: Patch<T>
}
/* -------------------------------------------------- */
/* Generic API */
/* -------------------------------------------------- */
export class StateManager<T extends object> {
private _id: string
private _current: T
private _previous: T
private _snapshot: Patch<T>
private _pointer = -1
private _stack: Command<T>[] = []
private _store: StoreApi<T>
private _context: React.Context<this>
public useAppState: UseStore<T>
constructor(initial: T, id = 'state', reset = false) {
this._id = id
this._current = initial
this._store = createVanilla(() => this._current)
this.useAppState = createReact(this._store)
if (reset) {
idb.del(id)
}
idb.get<T>(id).then((savedState) => {
if (savedState) {
this._current = savedState
this._previous = savedState
this._store.setState(savedState)
}
})
this._context = React.createContext(this)
}
private merge = (a: T, b: Patch<T>) => {
const next = merge<T>(a, b as any, { arrayMerge: (a, b) => b })
return this.clean(next)
}
protected clean = (state: T) => {
return state
}
public patchState = (patch: Patch<T>) => {
this._current = this.merge(this.state, patch)
this._store.setState(this.state)
return this
}
public setSnapshot(patch: Patch<T>) {
this._snapshot = patch
return this
}
public setState = (patch: Command<T>) => {
this._stack = [...this._stack.slice(0, this._pointer + 1), patch]
this._pointer = this._stack.length - 1
this._current = this.merge(this.state, patch.after)
this._previous = this.state
idb.set(this._id, this.state)
this._store.setState(this.state)
return this
}
public undo = () => {
if (this._pointer < 0) return this
const patch = this._stack[this._pointer]
this._pointer -= 1
this._current = this.merge(this.state, patch.before)
this._previous = this.state
idb.set(this._id, this.state)
this._store.setState(this.state)
return this
}
public redo = () => {
if (this._pointer >= this._stack.length - 1) return this
this._pointer += 1
const patch = this._stack[this._pointer]
this._current = this.merge(this.state, patch.after)
this._previous = this.state
idb.set(this._id, this.state)
this._store.setState(this.state)
return this
}
get current() {
return this._current
}
get previous() {
return this._previous
}
get snapshot() {
return this._snapshot
}
getState() {
return this._current
}
get state() {
return this.getState()
}
get context() {
return this._context
}
getPrevState() {
return this.previous
}
get prevState() {
return this.getPrevState()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment