Core Concepts

Overview

StateX lets you create a react based application with a flexible state management solution. Atoms are units of state that components can subscribe to. Selectors transform this state either synchronously or asynchronously.

Path

( string | number )[]

Path points to a unit of state. You can use path to read or write any node in your state tree. Path can be dynamic and constructed on the fly. StateX (aka State) tree is immutable. When an atom is updated, each subscribed component is re-rendered with the new value.

Demo

Atoms

Atoms are units of state at a specific path. They're updateable and subscribable: when an atom is updated, each subscribed component is re-rendered with the new value. They can be created at runtime, too. Atoms can be used in place of React local component state. If the same atom is used from multiple components, all those components share the same unit of state.

Atoms are created using the atom() function:

const fontSizeStateX = atom({
path: ['ui', 'fontSize'],
defaultValue: 11,
});

Atoms need a unique path, which is used for building the state JSON object with each segment in the path becoming the object key. Nested objects can be created with mulitiple segments in the path. It is an error for two atoms to have the same path, so make sure they're globally unique. Like React component state, they also have a default value.

To read and write an atom from a component, we use a hook called useStateX. It's just like React's useState, but now the state can be shared between components:

function FontButton() {
const [fontSize, setFontSize] = useStateX(fontSizeStateX);
return (
<button
onClick={() => setFontSize((size) => size + 1)}
style={{ fontSize }}
>
Click to Enlarge
</button>
);
}

Clicking on the button will increase the font size of the button by one. But now some other component can also use the same font size:

function Text() {
const [fontSize, setFontSize] = useStateX(fontSizeStateX);
return <p style={{ fontSize }}>This text will increase in size too.</p>;
}

Demo

Selectors

A selector is a pure function that accepts atoms or other selectors as input. When these upstream atoms or selectors are updated, the selector function will be re-evaluated. Components can subscribe to selectors just like atoms, and will then be re-rendered when the selectors change.

Selectors are used to calculate derived data that is based on state. This lets us avoid redundant state, usually obviating the need for reducers to keep state in sync and valid. Instead, a minimal set of state is stored in atoms, while everything else is efficiently computed as a function of that minimal state. Since selectors keep track of what components need them and what state they depend on, they make this functional approach more efficient.

From the point of view of components, selectors and atoms have the same interface and can therefore be substituted for one another.

Selectors are defined using the selector() function:

const fontSizeLabelStateX = selector({
path: ['fontSizeLabelStateX'],
get: ({ get }) => {
const fontSize = get(fontSizeStateX);
const unit = 'px';
return `${fontSize}${unit}`;
},
});

The get property is the function that is to be computed. It can access the value of any path, atoms and other selectors using the get argument passed to it. Whenever it accesses another path, atom or selector, a dependency relationship is created such that updating the other atom or selector will cause this one to be recomputed.

In this ['fontSizeLabelStateX'] example, the selector has one dependency: the ['fontSizeStateX'] atom. Conceptually, the ['fontSizeLabelStateX'] selector behaves like a pure function that takes a ['fontSizeStateX'] as input and returns a formatted font size label as output.

Selectors can be read using useStateXValue(), which takes a, path, atom or selector as an argument and returns the corresponding value. We don't use the useStateX() as the fontSizeLabelStateX selector is not writeable (see the selector API reference for more information on writeable selectors):

function FontButton() {
const [fontSize, setFontSize] = useStateX(fontSizeStateX);
const fontSizeLabel = useStateXValue(fontSizeLabelStateX);
return (
<>
<div>Current font size: ${fontSizeLabel}</div>
<button onClick={() => setFontSize(fontSize + 1)} style={{ fontSize }}>
Click to Enlarge
</button>
</>
);
}

Clicking on the button now does two things: it increases the font size of the button while also updating the font size label to reflect the current font size.

Demo

Actions

An action is a reusable pure function that has access to the StateX state and an ability to update the StateX state or invoke other actions. Unlike selector, actions will not subscribe to any changes to the atoms or selectors it reads.

Actions are used to keep multiple state updates in sync with some common, reusable logic. This lets us avoid duplicate code and disconnected state. Since action have full access to the state, it can perform state updates asyncronusly as well. With actions, you can modularize your business logic outside the component, thereby making the components lite weight.

Actions are defined using the action() function:

const activateNodeAction = action(({ get, set, call }, id: NodeID) => {
set(activeNodeIdAtom, id);
set(activeTabAtom, 1);
});

The function passed to action is to be executed on demand. It can access the value of any path, atoms, selectors using the get argument passed to it. It can update the state using the set argument and call any other action using the call argument.

In this activateNodeAction example, the action updates two atom states to mark a given nodeId as active and also set a specific tab as active.

Actions can be accessed using useStateXAction(), which takes the action as an argument and returns a callback function that can be invoked on user action.

Demo