• Stars
    star
    211
  • Rank 186,867 (Top 4 %)
  • Language
    TypeScript
  • License
    MIT License
  • Created over 3 years ago
  • Updated over 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

A state manager with undo, redo and persistence.

logo

Out of nowhere! A state management library for React with built-in undo, redo, and persistence. Built on Zustand.

logo

🧑‍💻 Check out the example project.

💜 Like this? Consider becoming a sponsor.

Table of Contents

Installation

npm install rko

or

yarn add rko

Usage

🧑‍🏫 Using TypeScript? See here for additional docs.

To use the library, first define your state as a class that extends StateManager. In your methods, you can use the StateManager's internal API to update the state.

// state.js
import { StateManager } from "rko"

class MyState extends StateManager {
  adjustCount = (n) =>
    this.setState({
      before: {
        count: this.state.count,
      },
      after: {
        count: this.state.count + n,
      },
    })
}

Next, export an instance of the state. If you want to persist the state, give it an id.

export const myState = new MyState({ count: 0 }, "my-state")

In your React components, you can use the state's useStore hook to select out the data you need. For more on the useStore hook, see zustand's documentation.

// app.jsx
import { myState } from "./state"

function App() {
  const { count } = myState.useStore((s) => s.count)
  return (
    <div>
      <h1>{count}</h1>
    </div>
  )
}

You can also call your state's methods from your React components.

function App() {
  const { count } = myState.useStore((s) => s.count)

  function increment() {
    myState.adjustCount(1)
  }

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={increment}>Increment</button>
    </div>
  )
}

...and you can use the StateManager's built-in methods too.

function App() {
  const { count } = myState.useStore((s) => s.count)

  function increment() {
    myState.adjustCount(1)
  }

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={increment}>Increment</button>
      <button onClick={myState.undo}>Undo</button>
      <button onClick={myState.redo}>Redo</button>
    </div>
  )
}

Right on, you've got your global state.

StateManager

The rko library exports a class named StateManager that you can extend to create a global state for your app. The methods you add to the class can access the StateManager's internal API.

import { StateManager } from "rko"

class AppState extends StateManager {
  // your methods here
}

You only need to create one instance of your StateManager sub-class. When you create the instance, pass an initial state object to its constructor.

const initialState = {
  // ...
}

export const appState = new AppState(initialState)

You can also use the constructor to:

Internal API

You can use StateManager's internal API to update your state from within your your sub-class methods.

patchState(patch: Patch<State>, id?: string)

Update the state without effecting the undo/redo stack. This method accepts a Patch type object, or a "deep partial" of the state object containing only the changes that you wish to make.

toggleMenuOpen = () =>
  this.patchState({
    ui: {
      menuOpen: !this.state.ui.menuOpen,
    },
  })

You can pass an id as setState's second parameter. This is provided to help with logging and debugging. The id will be saved in the history stack and be available in the onStateWillChange and onStateDidChange callbacks.

For example, this method:

 addMessage(newMessage) {
    this.patchState({ message: newMessage }, "added_message")
  }

Would cause onStateDidChange to receive added_message as its second argument.

setState(command: Command<State>, id?: string)

Update the state, push the command to the undo/redo stack, and persist the new state. This method accepts a Command type object containing two Patches: before and after. The after patch should contain the changes to the state that you wish to make immediately and when the command is "re-done". The before patch should contain the changes to make when the command is "undone".

adjustCount = (n) =>
  this.setState({
    before: {
      count: this.state.count,
    },
    after: {
      count: this.state.count + n,
    },
  })

Like patchState, you can provide an id as the method's second argument. Alternatively, you can provide the id as part of the command object. If you provide both, then the argument id will be used instead.

replaceState(state: State, id?: string)

Works like patchState but accepts an entire state instead of a patch. This is useful for cases where a deep merge may be too expensive, such as changing items during a drag or scroll interaction. Note that, like patchState, this method will not effect the undo/redo stack. You might also want to call resetHistory.

loadNewTodos = (state: State) =>
  this.replaceState({
    todos,
  })

cleanup(next: State, prev: State, patch: Patch<State>)

The cleanup method is called on every state change, after applying the current patch. It receives the next state, the previous state, and the patch that was just applied. It returns the "final" updated state.

cleanup = (next: State) => {
  const final = { ...state }

  for (const id in todos) {
    if (todos[id] === "undefined") {
      delete final.todos[id]
    }
  }

  return final
}

You can override this method in order to clean up any state that is no longer needed. Note that the changes won't be present in the undo/redo stack.

You can also override this method to log changes or implement middleware (see Using Middleware).

ready

The ready Promise will resolve after the state finishes loading persisted data, if any.

const state = new Example()
const message = await state.ready
// message = 'none' | 'migrated' | 'restored'

onReady()

The onReady method is called when the state is finished loading persisted data, if any.

class Example extends StateManager {
  onReady() {
    console.log("loaded state from indexdb", this.state)
  }
}

onPatch(state: State, id?: string)

The onPatch method is called after the state is changed from an onPatch call.

onCommand(state: State, id?: string)

The onCommand method is called after the state is changed from an onCommand call.

onPersist(state: State, id?: string)

The onPersist method is called when the state would be persisted to storage. This method is called even if the state is not actually persisted, e.g. an id is not provided.

onReplace(state: State)

The onReplace method is called after a call to replaceState.

onReset(state: State)

The onReset method is called after a call to resetState.

onResetHistory(state: State)

The onResetHistory method is called after a call to resetHistory.

onStateWillChange(state: State, id?: string)

The onStateWillChange method is called just before each state change. It runs after cleanup. Your React components will not have updated when this method runs.

onStateWillChange = (state: State, id: string) => {
  console.log("Changing from", this.state, "to", state, "because", id)
  // > Changed from {...} to {...} because command:toggled_todo
}

Its first argument is the next state. (You can still access the current state as this.state). The id argument will be either patch, command, undo, redo, or reset.

You can override this method to log changes or implement middleware (see Using Middleware). If you're interested in what changed, consider using the cleanup method instead.

onStateDidChange(state: State, id?: string)

The onStateDidChange method works just like onStateWillChange, except that it runs after the state has updated. Your React components will have updated by the time this method runs.

onStateDidChange = (state: State, id: string) => {
  console.log("Changed to", state, "because", id)
  // > Changed to {...} because command:toggled_todo
}

snapshot

The most recently saved snapshot, or else the initial state if setSnapshot has not yet been called. You can use the snapshot to restore earlier parts of the state (see Using Snapshots). Readonly.

Public API

The StateManager class exposes a public API that you can use to interact with your state either from within your class methods or from anywhere in your application.

undo()

Move backward in history, un-doing the most recent change.

redo()

Move forward in history, re-doing the previous undone change.

reset()

Reset the state to its initial state (as provided in the constructor). This is not undoable. Calling reset() will also reset the history.

replaceHistory(stack: Command[], pointer?: number)

Replace the state's history. By default the pointer will be set to the end of the stack. Note that it's your responsibility to ensure that the new history stack is compatible with the current state!

resetHistory()

Reset the state's history.

forceUpdate()

Force the state to update.

setSnapshot()

Save the current state to the the snapshot property (see Using Snapshots).

useStore

The zustand hook used to subscribe components to the state.

pointer

The current pointer. Readonly.

state

The current state. Readonly.

status

The current status of the state: ready or loading. If restoring a persisted state, the state will briefly be loading while the state is being restored (see Persisting the State). Readonly.

canUndo

Whether the state can undo, given its undo/redo stack. Readonly.

canRedo

Whether the state can redo, given its undo/redo stack. Readonly.

Advanced Usage

Using with TypeScript

To use this library with TypeScript, define an interface for your state object and then use it as a generic when extending StateManager.

import { StateManager, Patch, Command } from "rko"

interface State {
  name: string
  count: number
}

class MyState extends StateManager<State> {
  // ...
}

Depending on how you're using the library (and your TypeScript config), you might also need the library's Patch and Command types. Both take your state interface as a generic.

cleanup = (next: State, prev: State, patch: Patch<State>) => {
  log(patch)
  return next
}

Persisting the State

To persist the state, pass an id string to the class constructor.

export const appState = new AppState({ count: 0 }, "counter")

The library will now save a copy of the state after each new call to setState, undo, redo, or reset. The next time you create a new instance of your StateManager sub-class, it will restore the state from the persisted state.

Because restoring a state is done asynchronously, the provided initial state will be used on your app's first render. To avoid a flash of content as the app loads, you can use the state's status property, which may be either loading or ready.

function App() {
  const { count } = myState.useStore((s) => s.count)

  if (myState.status === "loading") {
    return null
  }

  return <h1>{count}</h1>
}

Upgrading the Persisted State

The constructor also accepts a version number. If you want to replace the persisted state, you can bump the version number.

const initialState = { wins: 0, losses: 0 }

// Will persist state under the key 'game'
export const appState = new AppState(initialState, "game", 1)

By default, if the constructor finds a persisted state with the same id but a lower version number it will replace the persisted state with the initial state that you provide.

const initialState = { wins: 0, losses: 0, score: 0 }

// Will replace any previous 'game' state with a version < 2
export const appState = new AppState(initialState, "game", 2)

If you want to migrate or "upgrade" the earlier persisted state instead, you can pass a function that will receive the previous state, the new state, and the previous version number, and return the new state for this version.

const initialState = { wins: 0, losses: 0, score: 0 }

export const appState = new AppState(
  initialState,
  "game",
  2,
  (prev, next, version) => ({
    ...prev,
    score: prev.wins * 10,
  })
)

Note that this "upgrade" function will only run when an earlier version is found on the user's machine under the provided key.

Using Middleware

To use middleware or run side effects when the state changes, override the cleanup method in your StateManager sub-class.

cleanup = (next, prev, patch) => {
  // Log an ID from the patch?
  if (patch.patchId) {
    logger.log(patch.patchId)
  }

  // Create a JSON patch and update the server?
  const serverPatch = jsonpatch.compare(prev, state)
  server.sendUpdate(clientId, serverPatch)

  return next
}

Remember that cleanup runs before the new state is passed to the zustand store. Your components will not yet have received the new state.

Using Snapshots

Depending on your application, you may need to restore data from an earlier state. You can use the snapshot property and setSnapshot method to make this easier.

For example, if a user is was editing a todo's text, they would likely want to "undo" back to the text as it was before they began editing, and "redo" to the text as it was when they finished editing.

To do this, you would call setSnapshot when the user focuses the text input, in order to preserve the state before the user begins typing.

beginEditingTodo = () => {
  this.setSnapshot()
}

As the user types, you would call patchState in order to change the state without effecting the undo/redo stack.

editTodoText = (id, text) => {
  this.patchState({
    todos: {
      [id]: { text: state.todos[id].text },
    },
  })
}

Finally, when the user finishes or blurs the text input, you would call setState to create a new command—and in that command, using the snapshot info in the command's before patch.

finishEditing = (id) => {
  const { state, snapshot } = this

  this.setState({
    before: {
      todos: {
        [id]: { text: snapshot.todos[id].text },
      },
    },
    after: {
      todos: {
        [id]: { text: state.todos[id].text },
      },
    },
  })
}

Testing

You can using a library like jest to test your rko state. In addition to testing your React components, you can also test your state in isolation.

One way to test is by importing your StateManager sub-class and creating new instances for each test.

// state.test.js

import { MyState } from "./state"

describe("My State", () => {
  it("Increments the count (do, undo and redo)", () => {
    const myState = new MyState({ count: 0 })
    myState.adustCount(1)
    expect(myState.state.count).toBe(1)
    myState.undo()
    expect(myState.state.count).toBe(0)
    myState.redo()
    expect(myState.state.count).toBe(1)
  })
})

Alternatively, you can import your sub-class instance and use the reset method between tests to restore the initial state.

// state.test.js

import { myState } from "./state"

describe("My State", () => {
  it("Increments the count", () => {
    myState.adustCount(1)
    expect(myState.state.count).toBe(1)
  })

  it("Decrements the count", () => {
    myState.reset()
    myState.adustCount(-1)
    expect(myState.state.count).toBe(-1)
  })
})

Tips

Your StateManager sub-class is a regular class, so feel free to extend it with other properties and methods that your methods can rely on. For example, you might want multiple snapshots, a more complex status, or asynchronous behaviors.

Examples

Support

Please open an issue for support.

Discussion

Have an idea or casual question? Visit the discussion page.

Author

More Repositories

1

perfect-freehand

Draw perfect pressure-sensitive freehand lines.
HTML
3,435
star
2

perfect-arrows

Draw perfect arrows between points and shapes.
TypeScript
2,515
star
3

state-designer

State management with statecharts.
HTML
619
star
4

perfect-cursors

Perfect interpolation for multiplayer cursors.
TypeScript
494
star
5

telestrator

A disappearing drawing tool for your screen.
TypeScript
351
star
6

globs

A globs-based vector editor.
TypeScript
237
star
7

perfect-freehand-dart

Draw perfect freehand lines—in Flutter.
Dart
236
star
8

gotcha

Turn your Framer prototype into its own live developer spec.
CoffeeScript
114
star
9

liquorstore

A reactive store.
TypeScript
107
star
10

kdtype

A typing game for kids.
TypeScript
71
star
11

trashly

A reactive store.
TypeScript
53
star
12

polyclip-js

A JavaScript implementation of the Greiner-Hormann clipping algorithm.
TypeScript
47
star
13

arrows-playground

A canvas-based arrows playground.
TypeScript
37
star
14

state-designer-ide

A design environment for State Designer.
TypeScript
34
star
15

framer-moreutils

Expand Utils with some handy helper functions.
JavaScript
31
star
16

framework

A general-purpose component kit for Framer.
JavaScript
30
star
17

fontloader

Painlessly, reliably load local and web fonts into Framer prototypes.
CoffeeScript
30
star
18

arena-2022

An isometric game.
TypeScript
25
star
19

finder-toolbar-shortcuts

Easy shortcuts for Finder's toolbar.
Rich Text Format
25
star
20

react-motion-asteroids

An astroids-like game in React using Framer Motion.
TypeScript
24
star
21

personal-blog

A personal blog.
TypeScript
24
star
22

figma-plugin-perfect-freehand

A Figma plugin for drawing perfect freehand strokes.
TypeScript
24
star
23

replisketch

A collaborative drawing app built with Replicache.
TypeScript
19
star
24

framer-tools

Do good stuff fast in Framer X from the command line.
JavaScript
18
star
25

framer-controller

Control a Framer X component through overrides.
TypeScript
18
star
26

framer-button

A customizable button class for Framer prototypes.
CoffeeScript
15
star
27

tetris-react-state-designer

A Tetris implementation using React and State Designer.
TypeScript
14
star
28

framer-sublime-text

A Framer UI / Color Scheme / Syntax for Sublime Text
14
star
29

brush-engine

A brush engine for the browser.
TypeScript
13
star
30

together

A multiplayer experience.
TypeScript
13
star
31

inventory-react-state-designer

An inventory system in React and State Designer.
TypeScript
12
star
32

framer-md

A Material Design UI kit for Framer.
JavaScript
12
star
33

short-story

Small, self-contained interactive component demos.
JavaScript
12
star
34

framer-icon

Create SVG icons using this very simple module.
CoffeeScript
10
star
35

react-turtle

Turtle Graphics for React.
JavaScript
10
star
36

olc_rust_sketches

Learning rust with the olc Pixel Game Engine.
Rust
10
star
37

gnrng

A minimal seeded random number generator.
TypeScript
10
star
38

ActionLayer

An ActionLayer extends Layer, adding properties and functions designed to simplify managing events in Framer.
CoffeeScript
9
star
39

react-decal

A miniature canvas game engine in React.
TypeScript
9
star
40

quick-docs

Docs, quick.
JavaScript
8
star
41

framer-layout

Layout with grids in Framer.
CoffeeScript
8
star
42

arena-game

A tactical combat game in React.
TypeScript
6
star
43

react-use-maho

A state management tool based on statecharts.
TypeScript
6
star
44

snowcraft

Snowcraft brood war by Steve Ruiz.
TypeScript
6
star
45

FocusComponent

Control events among a group of layers.
CoffeeScript
5
star
46

state-designer-examples

Created with CodeSandbox
TypeScript
5
star
47

flow-docs

Docs for the flow component
JavaScript
4
star
48

MonokaiFade

Example of the monokai.nl-style fade effect.
TypeScript
3
star
49

perfect-freehand-signature

A pressure-based vector signature component.
3
star
50

learn-docs

Docs for the Learn Design System
JavaScript
3
star
51

bendy-arrows-playground

Created with CodeSandbox
TypeScript
3
star
52

short-story-sjs

Beautiful component previews for design, docs and demos.
TypeScript
2
star
53

unstyled-challenge

Design the web with strict limits on style.
JavaScript
2
star
54

steveruizok.github.io

UI/UX Portfolio
CSS
2
star
55

react-iso-engine

Isometric world engine written in React.
TypeScript
2
star
56

docs-mdx-cms

JavaScript
2
star
57

nextjs-content-starter

A Next.js static site with MDX.
TypeScript
2
star
58

brushy-brushy

TypeScript
2
star
59

stencil-refuge

App running on Refuge Restrooms API, built in Stencil.
JavaScript
1
star
60

emoji-picker-site

A tiny site for emoji picking.
CSS
1
star
61

st-lookbook

Webcomponents for previewing other components.
TypeScript
1
star
62

stencil-router-redux-demo

An example stencil project with stencil-router and stencil-redux working together.
HTML
1
star
63

framer-atomic-tutorial

Designing with progressive complexity, step-by-step in Framer.
JavaScript
1
star
64

react-use-three

React Hooks for three.js
TypeScript
1
star
65

docs-sites

Collection of small docs sites.
JavaScript
1
star
66

ds-docs-starter

A starter for design system documentation.
JavaScript
1
star
67

component-preview

A micro storybook-like environment for previewing components.
TypeScript
1
star
68

exp-apollo-hooks-todo

Apollo's todo list example with Apollo hooks.
JavaScript
1
star
69

framer-loupe-data

Nice website for designing with data at Framer Loupe.
JavaScript
1
star