Warning This library has been built for experimental purposes for my needs while building apps that need an agnostic state manager and a certain complexity.
StateBuilder
is an agnostic state management library built on the top of SolidJS reactivity.
It's built to be an extremely modular system, with an API that allows you to add methods, utilities and custom behaviors to your store in an easier way. Of course, this come with a built-in TypeScript support.
Solid already provides the primitives to build a state manager system thanks to signals and stores. What's missing is a well-defined pattern to follow while building your application.
Thanks to StateBuilder
you can compose the approach you like to handle your state.
StateBuilder
come to the rescue introducing some concepts:
The state container it's a plain JavaScript object that collects all resolved store instances. Once created, every state
container will have his own reactive scope, introduced by the Owner
object from solid-js API.
The store creator it's the function that define your store api implementation, which requires you to follow a
specific signature to be complaint to StateBuilder
API.
StateBuilder
already comes with two built-in store creators:
- defineStore
- defineSignal
Using the store definition creator, you can define where you want your state, that will be lazy evaluated only once you inject it.
Plugins are the core of StateBuilder
. They are basically configurable objects or
functions that override your store's signature, adding new features or modifying existing ones.
They are not only here to define your store internals or the pattern you want to use (persistance, redux-like, rxjs integration etc.), but you can also create mini-modules that can be reused in your app.
graph TD
A[Store]
A -->|Extend| B[Redux Plugin]
A -->|Extend| C[RxJS Plugin]
A -->|Extend| D[LocalStorage Plugin]
A -->|Extend| E[Entity Plugin]
A -->|Extend| G[Devtools Plugin]
Install StateBuilder
by running the following command of the following:
pnpm i statebuilder # or npm or yarn
Create the store container with the Container.create()
api.
// container.ts
import { Container } from 'statebuilder';
import { createRoot } from 'solid-js';
export const stateContainer = createRoot(() => Container.create());
Once the Container is created, you can define the store state through the defineStore
or defineSignal
function.
// count.ts
import { defineSignal } from 'statebuilder';
import { createEffect } from 'solid-js';
const CountStore = defineSignal(() => 0)
.extend((state) => ({
increment: () => state.set((prev) => prev + 1),
decrement: () => state.set((prev) => prev - 1),
}))
.extend((state) => {
createEffect(() => {
console.log('on state change', state());
});
});
Both utilities basically use under the hood the createSignal
and createStore
primitives from SolidJS. The main difference is that the define*
api deals only to defining how these store will be
created, then they will only be initialized once the state is injected.
Next, the state can be injected through the Container
. Each container collects stores as singletons, so once created
the same instance of the definition will be shared.
Both functions are used to define a store with a state. The first argument is the initial value of the state.
Next, you can extend your store definition with the .extend()
method.
Note The
.extend()
method is fully typesafe and chainable, allowing you to the use multiple plugin at once.
As anticipated, at the moment we have only defined the configuration of the store. The next step is to initialize it using the container we created earlier.
import { stateContainer } from './container.ts';
import { CountStore } from './count';
const count = stateContainer.get(CountStore);
count(); // get the state accessor
count.set((count) => count++); // set the state manually
// The returned state will inherit all properties returned by the .extend() method π
count.increment(); // increment;
count.decrement(); // decrement;
createEffect(() => {
console.log('state changed', count());
});
As already said in the Architecture paragraph, StateBuilder
core is powered by a pluggable system.
Plugins can be defined in two ways:
- Through a function that extends the store object
- Through a
Plugin
configuration object
The first recommendation is to split your store extension in plugins where needed, for example
when you have to reuse some business logic, and prefers the makePlugin
API when you create generic plugins (
e.g. LocalStoragePlugin
), in order to simplify the TS typings.
import { createEffect, on } from 'solid-js';
const CountStore = defineSignal(() => 0).extend((state) => {
if (localStorage.has('count')) {
state.set(JSON.parse(localStorage.get('count')));
}
createEffect(on(state, (count) => localStorage.set('count', count)));
return {
increment: () => state.set((prev) => prev + 1),
decrement: () => state.set((prev) => prev - 1),
};
});
In the plugin created earlier, we could split the logic into two different plugins:
- A plugin which updates the localStorage on state change
- A plugin which augments the state with the state methods
import { makePlugin } from 'statebuilder';
const withLocalStorage = (key: string) =>
makePlugin(
(state) => {
// Will be called once during state initialization
if (localStorage.has(key)) {
const value = JSON.parse(localStorage.get(key));
state.set(key);
}
createEffect(on(state, (count) => localStorage.set('count', count)));
return {};
},
{ name: 'withLocalStorage' },
);
const CountStore = defineSignal(() => 0)
.extend(withLocalStorage('count'))
.extend((state) => {
return {
increment: state.set((prev) => prev + 1),
decrement: state.set((prev) => prev - 1),
};
});
import { makePlugin } from 'statebuilder';
interface StoreWithReducer<T, Action> {
dispatch(action: Action): void;
}
function reducerPlugin<T extends StoreValue, R>(
store: Store<T>,
reducer: (state: T, action: R) => T,
): StoreWithReducer<T, R> {
return {
dispatch(action: R) {
store.set((prevState) => reducer(prevState, action));
},
};
}
export const withReducer = makePlugin(reducerPlugin, { name: 'withReducer' });
In the example above, we get the state context, a reducer and we return a new object with a dispatch function that will
update the store thanks to the .set()
method.
Here is an example of what we have created.
import { defineStore, provideState } from 'statebuilder';
type Increment = { type: 'increment'; payload: number };
type Decrement = { type: 'decrement'; payload: number };
type AppActions = Increment | Decrement;
type AppState = {
counter: number;
};
function appReducer(state: AppState, action: AppActions) {
switch (action.type) {
case 'increment':
return { ...state, counter: state.counter + action.payload };
case 'decrement':
return { ...state, counter: state.counter - action.payload };
default:
return state;
}
}
const AppState = defineStore<AppState>(() => ({ counter: 0 })).extend(
(context) => withReducer(context, appReducer),
);
function Counter() {
const { get: state, dispatch } = provideState(AppState);
return (
<>
<h1>Count: {state.counter}</h1>
<button onClick={() => dispatch({ type: 'increment', payload: 1 })}>
Increment
</button>
<button onClick={() => dispatch({ type: 'decrement', payload: 1 })}>
Increment
</button>
</>
);
}
Before using statebuilder
on SolidJS, it's recommended to mount the StoreProvider
to your app, ideally at the root.
This is needed to fix an issue with node and SSR while using global state managers.
https://vuejs.org/guide/scaling-up/ssr.html#cross-request-state-pollution
The StoreProvider
will manage all lifecycles and instances of your store. It act like a Container
;
import { StoreProvider } from 'statebuilder';
// Put in your root tree
<StoreProvider>
<App />
</StoreProvider>;
Once your store definition is ready, you can inject the store in your components by using the provideState
helper.
import { provideState } from 'statebuilder';
import { CountStore } from './count';
function Counter() {
const count = provideState(CountStore);
return (
<>
<h1>Count: {count()}</h1>
<button onClick={() => count.increment()}>Increment</button>
</>
);
}
// TODO
- statebuilder/commands: state management system with a command-event based approach
- statebuilder/asyncAction: asynchronous actions handler with promise and observables
- statebuilder/devtools: Redux devtools integration
// TODO
- LocalStoragePlugin: https:/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/plugins/local-storage.ts
- EntityPlugin: https:/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/plugins/withEntityPlugin.ts
- IndexedDBPlugin: https:/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/plugins/withIndexedDbPlugin.ts
https:/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/editor/frame.ts https:/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/presets/presets.ts https:/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/presets/bridge.ts