mfellner / valtio-factory

Create and compose valtio state using the factory pattern.
https://codesandbox.io/s/valtio-factory-example-j7v2s
MIT License
41 stars 2 forks source link

valtio-factory 🏭

Build Status Codecov Build Size Version Downloads

Create valtio state using the factory pattern

A proxy object is created from initial state. Existing valtio functions can be used normally.

import { createFactory } from '@mfellner/valtio-factory';
import { subscribe } from 'valtio';

const state = createFactory({ count: 0 }).create();

state.increment();

subscribe(state, () => console.log('state:', state));

Motivation

Valtio already offers several simple recipes for organizing actions, persisting state, and composing states.

This library provides a comprehensive and opinionated solution on top valtio for creating "stores" (state + actions) using the factory pattern. Specifically, it simplifies the following things:

valtio-factory was partially inspired by MobX-State-Tree.

Define actions

Actions become methods on the state itself. This is equivalent to manually declaring actions as properties of the proxy object.

const state = createFactory({ count: 0 })
  .actions({
    increment() {
      this.count += 1;
    },
  })
  .create();

state.increment();

Use context

A context object can be provided to actions and will be available as the property this.$context. The context object will be part of the state as a transitive ref property.

Context can be used to provide external dependencies to actions, e.g. API client instances.

type State = {
  count: number;
};

type Context = {
  shouldIncrement: boolean;
};

const state = createFactory<State, Context>({ count: 0 })
  .actions({
    increment() {
      if (this.$context.shouldIncrement) state.count += 1;
    },
  })
  .create({ shouldIncrement: true });

Actions with return types

Actions may also return a value to the caller:

const state = createFactory({ count: 0 })
  .actions({
    increment(): number {
      this.count += 1;
      return this.count;
    },
  })
  .create();

const n: number = state.increment();

Actions calling other actions

Actions can call previously defined actions. Note that it's necessary to call the actions factory function twice and only the the second action can call the first action, not the other way around.

const state = createFactory({ count: 0 })
  .actions({
    increment(n: number) {
      this.count += n;
    },
  })
  .actions({
    double() {
      this.increment(this.count);
    },
  })
  .create();

Derive properties

The derived factory function is a convenient wrapper around the derive utility.

const state = createFactory({ count: 0 })
  .derived({
    doubled(state) {
      return state.count * 2;
    },
  })
  .actions({
    double() {
      // Derived properties are available in subsequently declared actions.
      state.count = state.doubled;
    },
  })
  .create();

Provide initial state on initialization

The second argument of the create method is used to initialise the proxy and to overwrite the initial state. It's a partial object that can have some but doesn't need all of the state properties.

const state = createFactory({ count: 0, bool: true }).create(/* context */ undefined, { count: 1 });

Subscribe

It's possible to define subscriptions on the whole state using the factory pattern.

The subscription callback function receives the state object as a first argument, then the factory's context, and the rest of valtio's arguments for the subscribe callback last.

const state = createFactory({ count: 0 })
  .subscribe((state, context) => {
    console.log('current state:', state);
  })
  .create();

Subscribe to snapshots

To conveniently subscribe to a snapshot of the state, use subscribeSnapshot.

createFactory({ count: 0 }).subscribeSnapshot((snap, context) => {
  // `snap` is an immutable object
});

Use onCreate to subscribe only to portions of the state

You can use the onCreate method to declare a callback that will receive the proxy state object when it is created by the factory.

That way you can use all of valtio's utilities like subscribe and subscribeKey as you normally would.

onCreate may optionally return an unsubscribe callback function.

import { subscribeKey } from 'valtio/utils';

createFactory({ count: 0 }).onCreate((state) => {
  return subscribeKey(state, 'count', (n) => {
    console.log('current count:', n);
  });
});

Unsubscribe

The store exposes the function $unsubscribe() which will unsubscribe all subscriptions added to the factory. It wil also call the unsubscribe callback returned by the onCreate function.

const state = createFactory({ count: 0 })
  .subscribe((state) => {})
  .onCreate((state) => {
    // The function returned by onCreate will be called when $unsubscribe() is called.
    return subscribeKey(state, 'count', (n) => {});
  })
  .create();

state.$unsubscribe();

Compose factories

You can compose factories in order to create a proxy object of nested states.

const foo = createFactory({ x: 0 }).actions({
  inc() {
    this.x += 1;
  },
});

const bar = createFactory({ y: 0 }).actions({
  dec() {
    this.y -= 1;
  },
});

const root = createFactory({
  foo,
  bar,
});

const state = root.create(context, {
  // The initial state object will use the keys of the factory properties.
  bar: {
    y: 1,
  },
});

// The resulting proxy object will have properties with the individual state objects.
state.foo.inc();

Access the parent store

When composing factories and their resultant state, the parent store can be accessed with the $getParent() method inside actions.

Note that it's currently not possible to anticpate the type of the parent store and wether it will be defined. Hence it's necessary to supply a type parameter to the $getParent function and to use optional chaining.

import { createFactory, Store } from '@mfellner/valtio-factory';

const foo = createFactory({ x: 0 }).actions({
  inc() {
    this.x += 1;
  },
});

const bar = createFactory({ y: 0 }).actions({
  dec() {
    this.y -= this.$getParent?.<RootStore>()?.foo.x ?? 0;
  },
});

type FooFactory = typeof foo;
type BarFactory = typeof bar;
type RootState = {
  foo: FooFactory;
  bar: BarFactory;
};
type RootStore = Store<typeof root>;

const root = createFactory<RootState>({
  foo,
  bar,
});

TypeScript

Get the result type of a factory

For convenience, you can get the result type of a factory (i.e. the type of the proxy state) with the Store helper.

import { createFactory, Store } from '@mfellner/valtio-factory';

const counterFactory = createFactory({ x: 0 }).actions({
  inc() {
    this.x += 1;
  },
});

type Counter = Store<typeof counterFactory>;

const counter: Counter = counter.create();

Declare a state type

Using TypeScript type arguments, you can declare optional properties or properties with union types.

type State = {
  count?: number;
  text: string | null;
};

const state = createFactory<State>({ text: null }).create();

Use with React

Of course you can use valtio-factory with React.

const counter = createFactory({ x: 0 })
  .actions({
    inc() {
      this.x += 1;
    },
  })
  .create();

function Counter() {
  // Call `useSnapshot` on the store (i.e. proxy state object).
  const state = useSnapshot(counter);

  // Use action methods directly from the store, not from the state snapshot!
  const onInc = () => counter.inc();

  return (
    <div>
      <p>count: {state.x}</p>
      <button onClick={onInc}>increment</button>
    </div>
  );
}

Example

You can find an example with React and valtio-factory in this repository at example/ or on Codesandbox.