import {
createState,
createComputedState,
StatemanjsAPI,
} from "@persevie/statemanjs";
type Planet = {
name: string;
system: string;
satelites: string[];
hasLife: boolean;
distance: number;
averageTemperature: number;
};
type Coordinates = {
latitude: number;
longitude: number;
};
type Rover = {
planet: string;
name: string;
days: number;
batteryCharge: number;
status: string;
weatherOutside: string;
coordinates: Coordinates;
};
const planetState = createState<Planet>({
name: "Earth",
system: "Solar System",
satelites: [],
hasLife: true,
distance: 1_000_000,
averageTemperature: 15,
});
const planetStateUnsub = planetState.subscribe((state) => {
console.log("Planet state updated:", state);
});
const planetStateDistanceUnsub = planetState.subscribe(
(state) => {
console.log("Planet state distance updated:", state.distance);
},
{
properties: ["distance"],
},
);
planetState.update((state) => {
state.satelites.push("Moon");
});
planetState.update((state) => {
state.distance = 224_000_900;
});
planetState.set({
name: "Mars",
system: "Solar System",
satelites: ["Phobos", "Deimos"],
hasLife: false,
distance: 100,
averageTemperature: -63,
});
planetStateUnsub();
planetStateDistanceUnsub();
const marsExplorerState = createState<Rover>({
planet: "Mars",
name: "MarsExplorer",
days: 0,
batteryCharge: 100,
status: "On the way",
weatherOutside: "unknown",
coordinates: {
latitude: 0,
longitude: 0,
},
});
function generateReport(state: StatemanjsAPI<Rover>): string {
return `Rover report state updated. My status is ${
state.get().status
}. I'm on day ${state.get().days}. My battery charge is ${
state.get().batteryCharge
}. Weather outside is ${state.get().weatherOutside}. My coordinates are ${
state.get().coordinates.latitude
}, ${state.get().coordinates.longitude}.
My coordinates are: lat ${state.get().coordinates.latitude}, long ${
state.get().coordinates.longitude
}.
The weather outside is: ${state.get().weatherOutside}.`;
}
const marsExplorerDaysState = marsExplorerState.createSelector(
(state) => state.days,
);
marsExplorerDaysState.subscribe((state) => {
console.log("MarsExplorer Days state updated:", state);
});
const marsExplorerReportState = createComputedState<string>((): string => {
return generateReport(marsExplorerState);
}, [marsExplorerState]);
marsExplorerReportState.subscribe((state) => {
console.log(state);
});
marsExplorerState.set({
planet: "Mars",
name: "MarsExplorer",
days: 10,
batteryCharge: 85,
status: "Active",
weatherOutside: "Sunny",
coordinates: {
latitude: 4.5,
longitude: 137.4,
},
});
marsExplorerState.subscribe(
() => {
charge(marsExplorerState);
},
{ notifyCondition: (s): boolean => s.batteryCharge < 10 },
);
function charge(roverState: StatemanjsAPI<Rover>) {
roverState.asyncAction(async (state: StatemanjsAPI<Rover>) => {
console.log("Charging the rover...");
await new Promise((resolve) => setTimeout(resolve, 10000));
state.update((state) => {
state.batteryCharge = 100;
});
});
}
marsExplorerState.set({
planet: "Mars",
name: "MarsExplorer",
days: 8,
batteryCharge: 0,
status: "Inactive",
weatherOutside: "Sunny",
coordinates: {
latitude: -14.6,
longitude: 130.7,
},
});
Table of Contents
Introduction
Statemanjs is a framework-agnostic library for managing state in JavaScript and NodeJS applications.
Key features:
- High Performance: Statemanjs is built for speed and efficiency, particularly in large or complex applications. Recent updates have further optimized state management and subscription handling, ensuring top-tier performance.
- Reliability: With a strict API, immutable state management, and enhanced error handling, Statemanjs ensures that state changes are both reliable and secure.
- Flexible API: Statemanjs offers a clear, user-friendly API, now expanded with custom comparators, advanced subscription options, and enhanced computed state management. These additions make the library even more powerful and adaptable.
- Versatile State Support: Statemanjs can manage any data type as state, including primitives, complex objects, and multidimensional arrays, offering great versatility.
- Framework-Agnostic: While Statemanjs works independently, it also has packages available for popular front-end frameworks such as React, Vue, and Svelte, making it easy to integrate into a wide range of projects.
- TypeScript-Ready: Written in TypeScript, Statemanjs provides excellent type checking and inference, ensuring robustness and ease of integration into TypeScript projects.
- Lightweight: Despite its power, Statemanjs remains lightweight, with a bundle size of just 11.9 kB minified (3.1 kB minified and gzipped), keeping your project lean.
These features make Statemanjs a compelling choice for state management in modern JavaScript and TypeScript applications, combining performance, flexibility, and ease of use.
API
Any manipulations with your state are possible only through built-in methods, so they should be understandable and convenient.
The createState
method is used to create a state:
set(newState: T, options?: SetOptions<T>): boolean;
get(): T;
subscribe(
subscriptionCb: SubscriptionCb<T>,
subscriptionOptions?: SubscriptionOptions<T>,
): UnsubscribeCb;
unsubscribeAll(): void;
getActiveSubscribersCount(): number;
update(updateCb: UpdateCb<T>, options?: UpdateOptions<T>): boolean;
unwrap(): T;
asyncAction(
action: (stateManager: StatemanjsAPI<T>) => Promise<void>,
): Promise<void>;
createSelector<E>(
selectorFn: (state: T) => E,
subscriptionOptions?: SubscriptionOptions<unknown>,
): StatemanjsComputedAPI<E>;
DEBUG?: DebugAPI<T>;
The createComputedState
method is used to create a computed state:
createComputedState<T>(callback: () => T, deps: (StatemanjsAPI<any> | StatemanjsComputedAPI<any>)[]): StatemanjsComputedAPI<T>
StatemanjsComputedAPI<T>
get(): T;
subscribe(
subscriptionCb: SubscriptionCb<T>,
subscriptionOptions?: SubscriptionOptions<T>,
): UnsubscribeCb;
unsubscribeAll(): void;
getActiveSubscribersCount(): number;
unwrap(): T;
TransactionAPI<T>
totalTransactions: number;
addTransaction(snapshot: T): void;
getLastTransaction(): Transaction<T> | null;
getAllTransactions(): Transaction<T>[];
getTransactionByNumber(transactionNumber: number): Transaction<T> | null;
getLastDiff(): TransactionDiff<T> | null;
getDiffBetween(
transactionA: number,
transactionB: number,
): TransactionDiff<T> | null;
DebugAPI<T>
transactionService: TransactionAPI<T>;
Any data type as a state
A state can be anything from primitives to complex and multidimensional objects. Just pass this to the createState
function and use the state with no extra effort.
const isLoading = createState(true);
const soComplexObject = createState({
1: { 2: { 3: { 4: { 5: [{ foo: "bar" }] } } } },
});
Installation
npm i @persevie/statemanjs
Usage
To use Statemanjs, you'll need to create a state object and interact with it using the provided API methods.
Here's an example of creating a state object for storing a user's name:
import { createState } from "@persevie/statemanjs";
const userState = createState({ name: "Jake" });
You can also pass in the type of your state if you are using TypeScript:
import { createState } from "@persevie/statemanjs";
type User = {
name: string;
age: number;
};
const userState = createState<User>({ name: "Finn", age: 13 });
To get the current state, use the get
method.
const counterState = createState(1);
const counter = counterState.get();
Subscribe to changes
The subscribe
method takes a callback function and executes it on every state change. This callback function accepts the updated state.
const counterState = createState(0);
counterState.subscribe((state) => {
if (Number.isInteger(state)) {
console.log("it's integer");
} else {
console.log("it's not integer");
}
});
You can set a condition, notifyCondition
, under which the callback will be called. This condition is the second and optional parameter. If there is no condition, then the callback will fire on every state change. notifyCondition
also accepts the updated state.
const counterState = createState(0);
counterState.subscribe(
(state) => {
console.log("it's integer");
},
{ notifyCondition: (state) => Number.isInteger(state) },
);
To protect a subscriber - pass protect: true
to the second argument of the object. Protected subscribers can only be unsubscribed using the unsubscribe method returned by the subscribe
method.
const counterState = createState(0);
counterState.subscribe(
(state) => {
console.log("it's integer");
},
{ notifyCondition: (state) => Number.isInteger(state), protect: true },
);
You can specify which properties you want the subscriber to be notified when they change (at least one). If none of the properties have been changed, the subscriber will not be notified. Note that the set
method always replaces the state, so use the update
method to observe the properties correctly. Set is set.
const userState = createState({
name: "Jake",
surname: "Dog",
info: { hobbies: [] },
});
userState.subscribe(
(state) => {
console.log(`The name has been changed: ${state.name}`);
},
{ properties: ["name"] },
);
userState.subscribe(
(state) => {
console.log(
`Hobbies have been changed: ${state.info.hobbies.join(", ")}`,
);
},
{ properties: ["info.hobbies"] },
);
The subscribe
method returns a callback to unsubscribe.
const counterState = createState(0);
const unsub = counterState.subscribe(
(state) => {
console.log("it's integer");
},
{ notifyCondition: (state) => Number.isInteger(state) },
);
unsub();
To unsubscribe all active and unprotected subscriptions from a state, use the unsubscribeAll
method;
counterState.unsubscribeAll();
Sometimes you need to find out how many active subscriptions a state has, for this there is a getActiveSubscribersCount
method.
const subscribersCount = counterState.getActiveSubscribersCount();
State change
There are two ways to change the state - set
and update
. The set
method completely changes the state and is great for primitives and simple states.
const counterState = createState(0);
counterState.subscribe(
(state) => {
console.log("it's integer");
},
{ notifyCondition: (state) => Number.isInteger(state) },
);
counterState.set(2);
counterState.set(counterState.get() * 2);
The update
method is suitable for complex states (objects and arrays) in which only part of the state needs to be changed. The update
method accepts the current state.
import { createState } from "@persevie/statemanjs";
type User = {
name: string;
age: number;
isOnline: boolean;
hobbyes: Array<string>;
};
const userState = createState<User>({
name: "Finn",
age: 13,
isOnline: false,
hobbyes: [],
});
userState.update((state) => {
state.isOnline = !state.isOnline;
});
userState.update((state) => {
state.hobbyes.push("adventure");
});
Unwrap
If you want unwrap state to javascript object - use unwrap()
method:
import { createState } from "@persevie/statemanjs";
type User = {
name: string;
age: number;
isOnline: boolean;
hobbyes: Array<string>;
};
const userState = createState<User>({
name: "Finn",
age: 13,
isOnline: false,
hobbyes: [],
});
const unwrappedUser = userState.unwrap();
Computed state
You can create a computed state with the createComputedState
function. It returns an instance of statemanjs, but without the ability to set or update the state because of its specificity (see the StatemanjsComputedAPI
interface).
This function takes two parameters:
- A callback function to create a state value (run when at least one of the dependencies has been changed).
- An array of dependencies (an instance of statemanjs).
Computed state creates only protected subscribers.
const problemState = createState<boolean>(false);
const statusComputedState = createComputedState<string>((): string => {
return problemState.get()
? "Houston, we have a problem"
: "Houston, everything is fine";
}, [problemState]);
Selectors
You can create a selector for a state object to track changes only to it. A selector is a computed state, but only for the current state and its property.
const state = createState({ count: 0, value: 42 });
state.subscribe((newState) => {
console.log("State changed:", newState);
});
const countSelector = state.createSelector(
(currentState) => currentState.count,
);
countSelector.subscribe((newCount) => {
console.log("Count changed:", newCount);
});
Async actions
If you need to change state asynchronously, for example to set data from an api call, you can use the asyncAction
method. It takes a callback function with a state instance as a parameter.
const state = createState({ count: 0, value: 0 });
state.subscribe((newState) => {
console.log("State changed:", newState);
});
state.asyncAction(async (stateManager) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
stateManager.update((s) => {
s.count++;
});
});
Debug
Transactions
const arrState = createState([], { transactionsLen: 10 });
const gat = () => arrState.DEBUG.transactionService.getAllTransactions();
arrState.subscribe((state) => {
console.log("diff: ", arrState.DEBUG.transactionService.getLastDiff());
});
arrState.set([0, 1]);
const arr = [
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
];
async function te() {
for (let index = 0; index < arr.length; index++) {
const element = arr[index];
await new Promise((resolve) => setTimeout(resolve, 1000));
arrState.update((s) => {
s.push(element);
});
}
}
te().then(() => {
console.log(
"all transactions: ",
arrState.DEBUG.transactionService.getAllTransactions(),
);
});
Custom Comparators
Statemanjs
allows you to define custom comparator functions that determine how the state should be compared before it is updated. This feature is particularly useful when you need more control over the conditions under which the state is considered "changed."
Using Custom Comparators
When creating a state object with createState
or a computed state with createComputedState
, you can provide a customComparator
as part of the StatemanjsServiceOptions
. This custom comparator will be used if you set the defaultComparator
to "custom"
.
Example
import { createState } from "@persevie/statemanjs";
import _ from "lodash";
const state = createState(
{ name: "Finn", age: 13 },
{
defaultComparator: "custom",
customComparator: (a, b) => _.isEqual(a, b),
},
);
state.update((currentState) => {
currentState.age = 14;
});
In this example, the _.isEqual
function from lodash is used to perform deep equality checks on the state. The state will only be updated if the custom comparator determines that the new state is different from the current state.
Overriding Comparators in set and update
You can override the global comparator behavior in individual set or update operations by using the SetOptions and UpdateOptions respectively. This allows you to temporarily use a different comparator or skip comparison entirely for a specific operation.
Options:
skipComparison
: If set to true, the state will be updated without any comparison.comparatorOverride
: Overrides the global defaultComparator
for this operation. You can use "none", "ref", "shallow", or "custom".customComparatorOverride
: Provides a custom comparator to be used for this operation, but it only applies if comparatorOverride or the globaldefaultComparator
is set to "custom".
Example
state.update(
(currentState) => {
currentState.age = 15;
},
{
comparatorOverride: "custom",
customComparatorOverride: (a, b) => a.age === b.age,
},
);
In this example, the state will only update if the age property is different, as defined by the customComparatorOverride
. This comparator override is only effective because comparatorOverride is explicitly set to "custom".
Here are the available defaultComparator options:
- "none": The state will be modified without any comparison.
- "ref": The state will be modified if the new state is a different reference from the current state.
- "shallow": The state will be modified based on a shallow comparison, where only the first level of properties is compared.
By default, Statemanjs will use "ref" if no defaultComparator is specified.
Example Usage of Default Comparators
const state = createState(
{ name: "Jake", age: 28 },
{
defaultComparator: "shallow",
},
);
state.set({ name: "Jake", age: 29 });
In this example, shallow comparison is used, meaning the state will only update if any of the top-level properties have changed.
This flexibility allows you to optimize performance and control how your application responds to state changes.
Performance test
The examples of storage implementations for each state-manager (except statemanjs) were taken from the official documentation of these libraries.
Fill case
One by one adds n
elements to the array x
times. Where n
is a number from the array of numbers [1, 10, 100, 1000, 10000, 100000, 1000000, 2000000, 5000000, 10000000,
50000000] (countOfElements), and x
is the number of iterations (1 by default). If n = 5; x = 2
, that means to add 5
elements 2
times. The element
is an object {foo: "bar", baz: "qux"}
. Between iterations the storage is reset (empty array).
The average value for iterations is calculated and written as the result.
Think of this case as a TODO list with a simple structure, e.g. {title: string, notes: string}
.
The benchmark was run on a MacBook Pro m1 16gb.
You can run the benchmarks on your computer. You can also add new benchmarks or modify existing ones.
Read more about it here.
Below is a table with the results of the fill benchmark.
time in ms
❌ - means an error during execution or too long execution time (>6h).
Items | effector | mobx | redux | statemanjs |
---|
1 | 0.010970029979944229 | 0.01990541983395815 | 0.0040803998708724976 | 0.0020753702148795126 |
10 | 0.04626586981117725 | 0.11000874035060405 | 0.014035369530320167 | 0.010449579730629922 |
100 | 0.17841962995938956 | 0.4354520997777581 | 0.08275457009673119 | 0.06232665043324232 |
1000 | 1.208628780017607 | 2.586632479839027 | 0.8747471100464463 | 0.2421091901510954 |
10000 | 58.332799129989 | 31.700192469991745 | 52.266411220021546 | 2.2227349602803588 |
100000 | 13849.532463340052 | 322.1863979200646 | 12867.839250005782 | 27.505533350259064 |
1000000 | 2448118.7541659996 | 4473.258667119965 | 2354867.223542001 | 279.83934087000785 |
2000000 | ❌ | 9588.994868720061 | ❌ | 605.3742875201627 |
5000000 | ❌ | ❌ | ❌ | 1468.102162090242 |
10000000 | ❌ | ❌ | ❌ | 3185.2785096402094 |
50000000 | ❌ | ❌ | ❌ | 14499.883542001247 |
Statemanjs has significantly better performance than others.
This suggests that Statemanjs may be a good choice for state management in JavaScript applications that need to perform many updates on large data sets in a short period of time. It may also be a good choice for applications that need to perform updates on complex data structures, as Statemanjs is able to handle these updates more efficiently.
Integrations
Statemanjs is framework agnostic and can be used without additional packages. But for convenience, there are packages for the most popular frameworks - react, vue, solid. Statemanjs supports svelte out of the box and doesn't need any additional packages.
To work with additional packages, the main statemanjs package is required.
For contributors
See CONTRIBUTING.md.