soapstone
v1.0.0
Published
A bare bones React state manager.
Downloads
35
Readme
Soapstone
A bare bones React state manager.
Why Another State Manager?
I am frustrated with Zustand which became the very thing it was made to replace: a complicated and bloated library, much like Redux.
So, I present Soapstone to you. Atomic? Hook-based? I have no idea, but it's got a great developer experience.
Installation
npm install soapstoneCreating a Store
Start by declaring the state of your store:
interface MyStore {
user: {
name: string;
age: number;
};
todos: string[];
}Then pass your type into an instance of the Soapstone class, while giving it a default state:
const MyStore = new Soapstone<MyStore>({
user: {
name: "John Doe",
age: 22,
},
todos: [],
});Using the Store
In any React component, use the use method to reactively subscribe to the store:
function MyComponent() {
const store = MyStore.use();
return (
<span>
{store.user.name} is {store.user.age} years old
</span>
);
}However, using the use method without any arguments will subscribe you to the entire store, causing re-renders on every state change, anywhere in the tree. It's smarter to subscribe to a smaller, more relevant slice(s) of the store, causing re-renders on when the relevant values are changed:
function MyComponent() {
const name = MyStore.use((state) => state.user.name);
const age = MyStore.use((state) => state.user.age);
return (
<span>
{name} is {age} years old
</span>
);
}Mutating the Store
Use the mutate method to update the store's state:
function MyComponent() {
const name = MyStore.use((state) => state.user.name);
const age = MyStore.use((state) => state.user.age);
return (
<div>
<span>
{name} is {age} years old
</span>
<button
onClick={() => {
MyStore.mutate((draft) => {
draft.user.age++;
});
}}
>
Older!
</button>
</div>
);
}[!NOTE] Soapstone uses Immer internally to manage state updates while preserving immutability.
In the example above, changing
agecauses both the root object anduserto be recreated. This means that updatinguser.agealso creates a newuserobject reference, which triggers re-renders in any components subscribed touse((state) => state.user).
Setting the Store
If you would like a more hands-on way to modifying the data of the store, you can use the set method:
MyStore.set({
user: { age: 99, name: "Dead Doe" },
todos: [],
});[!NOTE] You must pass a fully complete tree to the
setmethod. Soapstone will not handle automatically merging your partial state with the current one.
[!WARNING] The
setmethod does not guarantee immutability as that is up to you. Depending on how you maintain immutability, certain subscriptions and reactivities won't trigger a re-render if you do not recreate relevant objects.
Persistence
If you would like your store to persist across page reloads, you can pass in a unique identifier which will be used to save your state to local storage:
const MyStore = new Soapstone<MyStore>(
{ ... },
"my-store",
);[!WARNING] This hydration with local data may cause issues if you're using server-side rendering which expects both the server and client to agree on the same initially rendered content. Please see the deferred hook below.
Soapstone is pretty good at saving the state of your store to local storage whenever necessary. It saves under the following situations (some methods are debounced by $2000\text{ms}$):
- When
beforeunloadis fired - When
setis called (debounced) - When
mutateis used (debounced) - When
storeis called
If that isn't enough for you, you can always use the store method to save your state immediately:
function MyComponent() {
return (
<button
onClick={() => {
MyStore.store();
}}
>
Save
</button>
);
}Uninitialized Stores
If you don't have enough data to create your initial state, you can pass a function that you can initialize later with the useInitialization hook:
const MyStore = new Soapstone<MyStore, [string, number]>((name, age) => {
return {
user: { name, age },
todos: [],
};
});You must initialize the store before using any of its methods:
function MyComponent() {
MyStore.useInitialization(api.getName(), api.getAge());
...
}[!NOTE] Calling
useInitializationonly works once and all subsequent calls will be ignored.
[!WARNING] This hydration with locally fetched data may cause issues if you're using server-side rendering which expects both the server and client to agree on the same initially rendered content. Please see the deferred hook below.
Deferred Hook
Sometimes, you may desire the server and client to agree on the same initial state, just for the client to override it upon mount. You can achieve this with the useDeferred which returns a dummy value, consistent across the server and client, and overrides it when the component mounts, only on the client:
interface MyStore {
name: string;
}
const MyStore = new Soapstone<MyStore>({ ... }, "my-store");
function MyComponent() {
const name = MyStore.useDeferred((state) => state.name, "Dummy Name");
return <span>{name} says hello!</span>;
}Subscribing
If reactivity doesn't tickle your fancy and you need a more traditional subscription-based/event-style system, you can use the on method:
const unsubscribeName = MyStore.on(
(state) => state.user.name,
(name) => {
console.log(`Name is now ${name}!`);
},
);You can unsubscribe whenever you please using the returned function from the on method:
unsubscribeName();Current State
If you don't have access to the state of the store in a component, or are outside of the realm of React entirely, you can use the state property to get the current state of the store:
console.log("Current state:", MyStore.state);Initial State
The initial state of a store is made accessible to you via the initial property:
console.log("This was the initial state:", MyStore.initial);