minimal-state

Simple and powerful reactive state management

Usage no npm install needed!

<script type="module">
  import minimalState from 'https://cdn.skypack.dev/minimal-state';
</script>

README

minimal-state

Probably the only React state management library I ever want to use.

  • 🚀 Optimized for fast development. API supports mutable + immutable code styles
  • ðŸ’Ą Perfect TypeScript support
  • 😎 Your state is a plain JS object. No bloated class, no proxy magic.
  • ðŸŠķ 1 kB minzipped - just drop it anywhere
yarn add use-minimal-state
import React from 'react';
import {use, set, update, on} from 'use-minimal-state';

// the state is just an object
const state = {count: 0};

function App() {
  // hook which returns fresh values, like useState
  let count = use(state, 'count');

  // there are two ways to update state:

  // 1. set() with a setState-like API
  let increment = () => set(state, 'count', count + 1);

  // 2. update() which only triggers component updates, is more flexible
  let setTo9000 = () => {
    state.count = 1000; // no magic, state is just an object
    state.count *= 9;
    update(state, 'count'); // update when you're ready
  };

  return (
    <>
      <div>{count}</div>
      <button onClick={increment}>+1</button>
      <button onClick={setTo9000}>9000</button>
    </>
  );
}

// behind the use() hook is a flexible event emitter API that you can use for other
// stuff as well:
on(state, 'count', c => console.log('The count is', c));

state.count = 10;
update(state, 'count'); // "The count is 10", updates component

set(state, 'count', 11); // "The count is 11", updates component

// set() and update() are synchronous
console.log(state.count); // "11"

Without React

A version of the library without the use hook is also available as separate npm package, which does not depend on React:

yarn add minimal-state

In fact, minimal-state has no external dependencies at all (and is only 800 bytes). It can be useful as a general-purpose reactive state / event-emitter. Other than use, the packages use-minimal-state and minimal-state are exactly equivalent.

API

The API of minimal-state adhers to the philosophy that...

It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures. — Alan Perlis

In our case there are two data types, which we call state and atom.

  • A state is any JS hashmap, like {} or {users: []}.
  • An atom is any value wrapped in a single-element array, like [1] or ["wow"] or [{users: []}].

Both of these types can be made reactive because they can be changed while still keeping a stable reference (unlike plain strings or numbers).

I worked hard to make a reactive API that is as simple and intuitive as possible.

Core API

// use one state attribute
use(state, key) // == state[key]

// use list of attributes, in a list
use(state, [key1, key2, ...]) // == [state[key1], state[key2], ...]

// use entire state
use(state) // == shallow copy of state

// use an atom
use(atom) // == atom[0], the atom's value


// update state attribute
update(state, key);

// update atom
update(atom)


// set attribute (value)
set(state, key, value);

// set attribute (function)
set(state, key, oldValue => value);

// set multiple values at once by merging
set(state, {key: value, otherKey: otherValue});

// set the value of an atom
set(atom, value)

// set the value of an atom (function)
set(atom, oldValue => value)

To understand update vs set, it is best to think of set(state, key, value) as a shortcut for

state[key] = value;
update(state, key);

Similarly, set(atom, value) is just

atom[0] = value;
update(atom);

Event Emitter API

The core API builds on top of four functions emit, on, off, clear that implement a simple event emitter. Every emit triggers a call to all listeners registered with on.

// emit event
emit(state, key, ...args);

// atom events don't have keys
emit(atom, ...args);

// listen to event with specific key
on(state, key, (...args) => {}); // (...args) are what is passed to emit

// listen to event with any key
on(state, (key, ...args) => {}); // (...args) are what is passed to emit

// listen to atom event
on(atom, (...args) => {}); // (...args) are what is passed to emit

// on() returns an unsubscribe function to stop listening
let unsubscribe = on(state, key, () => {});
unsubscribe();

// or unsubscribe directly (needs reference to the listener function)
let listener = (...args) => {};
off(state, key, listener);
off(atom, listener);

// unsubscribe all listeners (for all keys)
clear(state);
clear(atom);
/* TODO: clear(state, key) */

Internally, both update(state, key) and set(state, key, value) call emit(state, key, ...args) twice, but in slightly different ways:

update(state, key);
// calls:
emit(state, key, state[key]);
emit(state, undefined, key, state[key]);
// triggers:
on(state, key, value => {});
on(state, (key, value) => {});

set(state, key, value);
// calls:
emit(state, key, value, oldValue);
emit(state, undefined, key, value, oldValue);
// triggers:
on(state, key, (value, oldValue) => {});
on(state, (key, value, oldValue) => {});

That is, if on listeners need access to the value and the previous value, you always have to use set for changing it.

The undefined event is an internal "wildcard" event that gets triggered for every update.

Atom updates are a bit simpler:

update(atom);
// calls:
emit(atom, atom[0]);
// triggers:
on(atom, value => {});

set(atom, value);
// calls:
emit(atom, value, oldValue);
// triggers:
on(atom, (value, oldValue) => {});

Side note: use calls on internally but does not look at the emitted value, so you can trigger use(state, key) either with update(state, key) or with set(state, key, value) or even with emit(state, key).

Additional API / helper functions

once(state, key, listener);

Like on, but unsubscribes when triggered the first time.

await next(state, key);

Promise that resolves on the next emit (= promisified once).

is(state, key, value);
is(state, {key: value});
is(atom, value);

"Declarative" version of set which does nothing if the value did not change.

Object-oriented API

The main cost of the functional approach is that consumers of a state object have to import all the functions. This is a burden if you want your state to be encapsulated – e.g. you use minimal-state in a library/package and want to expose a state object to the outside to emit change events. Requiring your package consumers to import an additional peer dependency would be awkward.

This is where OO shines, and why we also provide an OO version with the core functions as methods on your state object:

const state = State({count: 0}); // call State() to add methods to state
state.set('count', 1);

// consumers don't need our library now:
export {state};

Full OO API:

import State, {pure} from 'use-minimal-state';

// create state instance (= shallow copy of initialState plus methods)
let state = State(initialState);

state.set(key, value); // set(state, key, value)
state.update(key); // update(state, key)
state.on(key, listener); // on(state, key, listener), returns unsubscribe
state.emit(key, ...args); // emit(state, key, ...args)
state.clear(); // clear(state)

// this is only available if State is imported from use-minimal-state
state.use(key); // use(state, key)

// get back a snapshot of the state without methods
pure(state);

// Example:
let state = State({count: 0});
state.set('count', 1);
JSON.stringify(pure(state)); // "{\"count\": 1}"