Experiments >

Svelte command store

Experiment #12814th April, 2021by Joshua Nussbaum

When UIs have many options, it’s a good idea to provide a way to undo the last change. Just in case a mistake is made.

From a software design perspective, undo/redo always means using the Command Pattern. With the command pattern, we don’t just mutate data directly, we track a log of each change. That gives us the ability to replay changes later.

Command store API

Svelte’s default writable store provides .set() and .update() to mutate the store:

const store = writable(initialValue)

// mutate store
store.set(state1)

// mutate store again
store.update($store => state2)

// state1 is now lost and we can't recover it

But then we lose the previous state.

Our store will need a bit more information, so that it can create a log that it can recover from.

The API would look like this:

// commandStore is a store that mutates via commands
// it gets a list of values and list of commands
const store = commandStore(initialValue, commandList)

// apply a command by passing the command name
// it logs the command and mutates the state
store.execute(commandName, args)

// undo the last command
store.undo()

// or redo it
store.redo()

Commands

When the store is defined, we’ll pass a list of available commands. Each command is just an object that has two functions: forward() and reverse().

const store = commandStore(initialValue, {
  // `updateSettings` is the command name
  updateSettings: {
    // apply change
    forward(state, settings) {
      const previous = state.settings
      state.settings = settings

      // NOTE: we always return the previous value
      // we need that for replaying this in reverse
      return { state, previous }
    },

    // undo change
    reverse(state, previous) {
      // apply the previous value
      state.name = previous

      // return the updated state
      return state
    },
  },

  // ... more commands here
})

Implementing the command store

The command store piggy-backs off Svelte’s writable() store, but it provides a different API:

  • commandStore.subscribe(cb)
  • commandStore(initialValue, commands) - Creates a command store. Requires and initial value and a list of command handlers.
  • commandStore.execute(commandName, args) - Executes a command, args is optional.
  • commandStore.undo() - Undo the command before the current pointer.
  • commandStore.redo() - Redo the command after the current pointer.
  • $commandStore.value - Current value of the store.
  • $commandStore.stack - List of mutations/commands that were executed.
  • $commandStore.pointer - Position of the last executed command in the stack.

Here’s what the code looks like:

import { writable } from 'svelte/store'

// define a factory method for a command-style store
export default function commandStore(initialValue, commands) {

  // define a store
  const store = writable({
    value: initialValue,
    stack: [], // where the list of commands go
    pointer: 0 // the current position of stack
  })

  // execute applies the command in a forward direction
  // and pushes it onto the stack
  store.execute = (type, args) => {
    store.update(({value, pointer, stack}) => {
      // find the command
      const command = commands[type]
      // execute it
      const { state, previous} = command.forward(value, args)

      // update the state, increase the pointer, and push onto the stack
      return {
        value: state,
        pointer: pointer + 1,
        stack: [...stack,  { type, args, previous }]
      }
    })
  }

  // undo reverses a command
  store.undo = () => {
    store.update($store => {
      const {value, pointer, stack} = $store

      // cannot go back
      if (pointer == 0) return $store

      // get the command data from the stack
      const { type, args, previous } = stack[pointer-1]
      // find the command
      const command = commands[type]
      // execute the command in the reverse direction
      const updated = command.reverse(value, args, previous)

      // update the state and pointer
      return { value: updated, pointer: pointer - 1, stack }
    })
  }

  // redo re-runs a command in the forward direction
  store.redo = () => {
    store.update($store => {
      const {value, pointer, stack} = $store

      // cannot go forward
      if (pointer == $store.stack.length) return $store

      // get the command data from the stack
      const { type, args } = stack[pointer]
      // find the command
      const command = commands[type]
      // execute the command in the forward direction
      const updated = command.forward(value, args)

      // update the state and pointer
      return { value: updated.state, pointer: pointer + 1, stack }
    })
  }

  return store
}

Code

https://svelte.dev/repl/f41d2e0055ab47a4bc873c92fc56484b?version=3.37.0

Demo

Notes

  • Support uncommitted changes. For example, the user is editing some setting, the changes should take effect immediately while they are typing, even if the command is only applied when they leave the field.
view all experiments

Stay tuned in

Learn how to add more experimentation to your workflow