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. You can also use some patterns already exposed by the library.
The state container it's a plain JavaScript object that collects all resolved store instances. Once created, every state
container will have it's own reactive scope, bound into a Owner
from solid-js API.
The store creator it's the function that define the store api implementation, which requires you to follow a
specific signature to be complaint to StateBuilder
API.
StateBuilder
already have with two built-in store creators:
- defineStore
- defineSignal
Using the store definition, you can define your state business logic, which will be lazy evaluated once the state is injected the first time.
Plugins are the core concept of StateBuilder
composable system. They are configurable objects or
functions that override your store's signature, adding new features or modifying existing ones.
They can also be used to create mini-modules that can be reused across the app or other libraries.
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
Note
The statebuilder plugin is optional. This means that all the core features works right out of the box without a custom build step
If you're using Vite with SolidJS, you can use the statebuilder
custom plugin, which provide debug and custom features through babel transforms.
dev
: Enable dev modeautoKey
: Allows to name your stores automatically, based on the constant name.stateProviderDirective
: Allows to wraps your SolidJS component into a StateProvider when they contains theuse stateprovider
directive.
import { defineConfig } from 'vite';
import { statebuilder } from 'statebuilder/compiler';
export default defineConfig({
// your vite config
plugins: [
statebuilder({
autoKey: true,
dev: true,
filterStores: [
// define your custom store name
],
experimental: {
transformStateProviderDirective: true,
},
}),
],
});
Define the store state through the definition creator function, such as defineStore
or defineSignal
.
Those utilities wrap under the hood the createSignal
and createStore
primitives from SolidJS, but they will be lazy initialized once the state is injected the first time.
// 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());
});
});
Note that first argument is an arrow function that defines the initial value of the state.
Next, you can extend your store definition with the .extend()
method, which is typesafe and chainable, allowing
you to use multiple plugin at once.
The state can be injected through the Container
. Each container collects stores as singletons, so once it is mounted,
the same instance of the store will be shared across it's boundaries.
// container.ts
import { Container } from 'statebuilder';
import { createRoot } from 'solid-js';
export const stateContainer = createRoot(() => Container.create());
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;
Warning
Currently the most safe way to create a Container in solid-js is using context, in order to fully support SSR mode and avoid global state pollution.
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://github.com/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/plugins/local-storage.ts
- EntityPlugin: https://github.com/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/plugins/withEntityPlugin.ts
- IndexedDBPlugin: https://github.com/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/plugins/withIndexedDbPlugin.ts
https://github.com/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/editor/frame.ts https://github.com/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/presets/presets.ts https://github.com/riccardoperra/codeimage/blob/main/apps/codeimage/src/state/presets/bridge.ts