/overstate

Infinitely composable state + actions 🎈

Primary LanguageTypeScriptMIT LicenseMIT

Build Status Code Coverage downloads version MIT License

size gzip size

Overstate 🎈

Infinitely composable state + actions

Why?

You want to write data models that would have worked 5 years ago and will still work in 5 years time.

What?

A data store that combines state + actions into a single model object, composed of other model objects.

By returning the state changes (or a promise with the changes) in your model functions, you can assemble powerful applications with asynchronous programming, lazy loading, type safety, and no boilerplate, all the while only using this library in a single file.

Table of Contents

Hello World

import { createStore } from 'overstate';

const store = createStore({
  name: 'World',
  setNameTo(aNewName) {
    return { name: aNewName };
  }
});

store.subscribe((model) => document.body.innerHTML = `Hello ${model.name}`);
store.update();

Calling store.update() initially renders "Hello World".

Calling store.model.setNameTo('😋') anytime renders "Hello 😋" and so on.

Counter

import { createStore } from 'overstate';

const store = createStore({
  count: 0,
  down() {
    return { count: this.count - 1 };
  },
  up() {
    return { count: this.count + 1 };
  }
});

store.subscribe((model) => document.body.innerHTML = `Count: ${model.count}`);
store.update();

Calling store.model.down() or store.model.up() updates the count and calls the function passed to store.subscribe with the new data.

For example, adding this code will increment the counter and render it every second:

setInterval(store.model.up, 1000);

API Reference

createStore

Creates a store from a source object, deep copying all values and proxying all functions to call store.update when executed.

Can optionally receive a second argument to customize behavior:

const store = createStore(model, {
  merge(target, source, createProxyFunction) {
    // Customize the way `source` is merged into `target`.
    // Don't forget to call `createProxyFunction` on functions to make them update the state automatically!
  },
  callFunction(fn, state, args) {
    // Customize the way functions are called.
    // If you prefer not to use `this`, you can change the
    // signature of your functions to `(state, ...args) => changes`
    // or even `(state) => (...args) => changes`
  }
});
Custom merge

This function performs a shallow merge instead of the default deep merge:

const store = createStore(model, {
  merge(target, source, createProxyFunction) {
    for (let key in source) {
      if (typeof source[key] === 'function') {
        // Proxy functions so they automatically resolve promises and update state
        target[key] = createProxyFunction(source[key], target);
      }
      else {
        target[key] = source[key]; // Yay, shallow merge! 🎉
      }
    }
    return target;
  }
});

Custom function calls

If you prefer using another format for your functions like (state, ...args):

const store = createStore({
    count: 0,
    down: (state) => ({ count: state.count - 1 }),
    up: (state) => ({ count: state.count + 1 }),
    add: (state, value) => ({ count: state.count + value })
  }, {
  callFunction: (fn, state, args) => fn(state, ...args)
});

Or if you like (state) => (...args) more:

const store = createStore({
    count: 0,
    down: (state) => () => ({ count: state.count - 1 }),
    up: (state) => () => ({ count: state.count + 1 }),
    add: (state) => (value) => ({ count: state.count + value })
  }, {
  callFunction: (fn, state, args) => fn(state)(...args)
});

Then, you can still call your functions the same way you normally would:

store.model.add(5); // store.model.count === 5
store.model.up();   // store.model.count === 6
store.model.down(); // store.model.count === 5

If you're using TypeScript, be aware that this will mess with the type definitions, because we're changing the function signature!

store.model

An object composed of all values and proxied functions passed to createStore.

To call suscriptions when proxied, model functions should return (or resolve to) an object.

store.set

Merges some data into the store model at the root level and calls store.update.

It's a built-in shortcut for this:

const store = createStore({
  set(data) {
    return data;
  }
});

In that case, store.set will do the same thing as store.model.set.

store.subscribe

Calls the passed function every time a model function that returns (or resolves to) an object is executed.

Returns an unsubscribe function that you can call to remove the subscription.

Tread lightly when rendering in subscriptions - they're not throttled or rate-limited in any way!

store.update

Calls all subscriptions manually. You usually only do this once after creating the store.

Features

Return Changes

A wise man once said:

Return the change that you wish to see in the world.

When you call a function that returns (or resolves to) an object, the data is deeply merged into the current model:

const store =
    createStore({ a: 1, b: { c: 2, d: 3 }, set: (data) => data                                  });
store.model.set({       b: {       d: 4 },                      setA: (value) => ({ a: value }) });
// New model:  ({ a: 1, b: { c: 2, d: 4 }, set: (data) => data, setA: (value) => ({ a: value }) });

In this case, set allows changing any property of the model, while setA only allows changing the a property.

Functions are proxied to automatically call store.update if they return (or resolve to) an object when executed.

So if you call store.model.setA(5), it will call store.update afterwards as well.

Asynchronous Functions

Promises are supported out of the box - store.update is called after the promise resolves:

export const CounterModel = {
  count: 0,
  async down() { // sweet async / await goodness 🍰
    const value = await Promise.resolve(-1); // Get the value from some remote server
    return { count: this.count + value });
  },
  up() { // ye olde promises 🧓
    return Promise.resolve(1).then((value) => ({ count: this.count + value });
  }
};

Composition

You can put objects inside objects:

export const ABCounterModel = {
  counterA: CounterModel,
  counterB: CounterModel
};

This allows you to build a hierarchical tree of data and functions.

Lazy Loading

So you want to do code splitting with webpack and have functions from the imported modules automatically call store.update data when executed?

Here are a few ways to do it:

Using the store directly

const store = createStore();

// Get a named export
import('./counter-model').then((exports) => store.set({ counter: exports.CounterModel }));
// Get multiple named exports
import('./another-model').then((exports) => store.set({ A: exports.ModelA, B: exports.ModelB }));
// Get default export
import('./yet-another-model').then((exports) => store.set({ C: exports.default }));
// Get all exports
import('./utils-model').then((exports) => store.set({ utils: exports }));

When the import promise resolves, the model's functions proxied from CounterModel will automatically call store.update when executed.

Using a function in the model

You shouldn't have to (and can't always) rely on the store being available. Encapsulating your models makes them decoupled from 3rd party libraries, which means they're easier to maintain and adaptable to flexible requirements.

So to lazy load data without touching the store, you can do this:

export const LazyLoadedModel = {
  set(data) {
    return data;
  },
  loadChildModels() {
    // Get a named export
    import('./counter-model').then((exports) => this.set({ counter: exports.CounterModel }));
    // Get multiple named exports
    import('./another-model').then((exports) => this.set({ A: exports.ModelA, B: exports.ModelB }));
    // Get default export
    import('./yet-another-model').then((exports) => this.set({ C: exports.default }));
    // Get all exports
    import('./utils-model').then((exports) => this.set({ utils: exports }));
  }
};

Then define your store and load the models:

const store = createStore({ lazy: LazyLoadedModel });
store.model.lazy.loadChildModels();

The child models will be inserted into the model's data when the import is done.

TypeScript

Overstate is written in TypeScript, so if you use it you get autocomplete and type checking out of the box.

Going back to the Counter example:

store.model.up(5); // [ts] Expected 0 arguments, but got 1.

Classes

If you prefer, you can use classes instead of objects:

export class CounterModel {
  count = 0;
  add(value: number) { // or `add(value) {` if you don't use TypeScript
    return { count: this.count + value }; // Yay, `this` is of type `CounterModel` 😄
  }
};

And then when creating your store:

const store = createStore(new CounterModel());
store.model.add('1'); // [ts] Argument of type '"1"' is not assignable to parameter of type 'number'.

Arrow Functions

Be careful with those if you're using this inside your model functions - as expected, it would refer to the parent context. Because functions are proxied when the store is created, class methods defined as arrow functions won't refer to the correct this either.

Debugging

First, make sure you have the Redux devtools extension for your browser. Then:

import { createStore } from 'overstate';
import { debug } from 'overstate/debug/redux-devtools';
import { CounterModel } from './counter-model';

const store = process.env.NODE_ENV === 'production' ? createStore(CounterModel) : debug(createStore(CounterModel));

Rendering

For examples with different view layers, see the CodePen collection.

Here's a counter example with picodom:

/** @jsx h */
import { createStore } from 'overstate';
import { app } from 'overstate/app/picodom';
import { h, patch as render } from 'picodom';
import { CounterModel } from './counter-model';

const Counter = ({ model }) =>
  <div>
    Your count is: {model.count}
    <button onclick={model.down}>-</button>
    <button onclick={model.up}>+</button>
  </div>
;

app({
  store: createStore(CounterModel),
  view: Counter,
  render
});

All functions in the model are bound to the correct context, so you can write onclick={model.up} instead of onclick={() => model.up()}.

The app function is a very thin layer on top of Overstate to reduce boilerplate.

You can pass a custom DOM element to render into as the second argument, which is document.body by default.

It also returns an unsubscribe function to stop rendering, effectively "destroying" your app, although the store will still work just fine.

app uses requestAnimationFrame by default to throttle rendering. Alternatively, provide your own function in app({ throttle: ... }).

FAQs

this is bad and you should feel bad 🦀

Hey, that's not a question! Anyway, if you prefer state or something else instead of this, you can use the callFunction option when creating a store, as described in the custom function calls section.

So this is cool, where can I find out more?

I'm glad you asked! Here are some useful resources: