Reactor, a type-safe state management library
Reactor is a state management library with a focus on type safety. It shares a similar architecture with redux-toolkit
, allowing for the creation and composition of different slices of state.
Reactor, like many of my projects, has been primarily built for my use cases. If you wish to extend the base functionality, you're encouraged to fork the package.
Library | Description |
---|---|
@iatools/reactor-core |
Zero-dependency library for type-safe state management. |
Reactor, a type-safe state management library
Guides
Installation
Reactor can be installed as a standalone library through your preferred package manager.
npm install @iatools/reactor-core
Because Reactor focuses on type-safe state management, you’re encouraged to define interfaces for your State
and Actions
:
interface CounterState {
value: number;
}
interface CounterActions {
increment: number;
decrement: number;
reset: never;
}
We can then provide these as type arguments to the createReactor
function.
import { createReactor } from "@iatools/reactor-core";
// ... CounterState, CounterActions
const counter = createReactor<CounterState, CounterActions>({
initialState: { value: 0 },
reducers: {
increment(state) {
return { ...state, value: state.value + 1 };
},
decrement(state) {
return { ...state, value: state.value - 1 };
},
reset(state) {
return { ...state, value: 0 };
},
},
});
This ensures that our Reducer
functions satisfy our type interface, but also provides us with type-safe ActionCreators
that we can use to transition our state.
console.log(counter.actions);
// {increment: ƒ, decrement: ƒ, reset: ƒ}
counter.actions.increment(1);
Often, you’ll want to combine different slices of state to represent the data your app cares about. To support this, the combineReactors
function allows you to combine different Reactors
into a CombinedReactor
. At this point, you're also able to specify external plugins that can operate on your store. The reactorLogger
plugin, for example, will log all the state changes and the actions that caused them to your console.
import { createReactor } from "@iatools/reactor-core";
// ... create reactors for `counter` and `todos`
const store = combineReactors({
reactors: { counter, todos },
plugins: [reactorLogger()],
});
store.actions.counter.increment(1);
Usage with RxDOM
Reactor has bindings for RxDOM library, distributed via the reactor-rxdom
package.
RxDOM, a zero-dependency JavaScript library for building reactive user interfaces
These bindings should be installed alongside reactor-core
and rxdom
packages can be done with your preferred package manager:
npm install @iatools/reactor-core @iatools/reactor-rxdom @iatools/rxdom
After the creation of your reactors using reactor-core
, use the composeReactor
factory to create a strongly-typed context provider and selector:
import { composeReactor } from "@iatools/reactor-rxdom";
// ... create reactor or combined reactor
const [Provider, selector] = composeReactor<typeof reactor>(({ props }) => {
return div({ content: props.content });
});
When rendering this provider, pass your reactor
as one of the props. This will make the reactor
available to all descendant components.
import { composeFunction } from "@iatools/rxdom";
const App = composeFunction(() => {
return Provider({
reactor,
content: [Child()],
});
});
To access this context in a child component, define which slice of the reactor you would like to access, and then use the selector
returned from composeReactor
to access that slice.
import { button } from "@iatools/rxdom";
import { ReactorContextProps } from "@iatools/reactor-rxdom";
// ... composed reactor
type ContextProps = ReactorContextProps<typeof reactor>;
interface ChildContext {
counterState: ContextProps["state"]["counter"];
counterActions: ContextProps["actions"]["counter"];
}
const Child = composeFunction<{}, ChildContext>(
({ context }) => {
return button({
content: [context.counterState.value],
onclick: () => {
context.counterActions.increment(1);
},
});
},
{
counterState: selector(props => props.state.counter),
counterActions: selector(props => props.actions.counter),
}
);
Docs
States
States
represent the current value of a given reactor. Since Reactor works best with type-driven development, we’re encouraged to define our States
in interface. The following provides an example of a state for a Counter
reactor.
interface CounterState {
value: number;
}
This CounterState
is then passed as a type argument to the createReactor
function, forcing us to define an initialState
that satisfies the interface.
const counter = createReactor<CounterState, {}>({
initialState: { value: 0 },
});
States
are incredibly important as Reactor uses these values to generate a type-safe API.
Actions
Actions
are responsible for transitioning Reactor States
. As is the case states, Reactor encourages you to define actions in an interface.
interface CounterActions {
increment: number;
decrement: number;
reset: never;
}
This action interface specifies that the increment
and decrement
actions expect a number value, while reset
never expects a value.
When we pass CounterActions
as the second type argument to createReactor
, Reactor will enforce that we provide a reducers
property which satisfies this interface.
const counter = createReactor<CounterState, CounterActions>({
initialState: { value: 0 },
reducers: {
increment(state, number) {
return { ...state, value: state.value + number };
},
decrement(state, number) {
return { ...state, value: state.value - number };
},
reset(state) {
return { ...state, value: 0 };
},
},
});
Like States
, Actions
are another fundamental type unit that Reactor relies on to support type-safe state management. These are the only two interfaces you'll need to declare to productively use Reactor.
Reducers
Reducer
are responsible for responding to different Actions
. Reactor doesn't require you to specify the types of your reducers as they are inferred from your State
and Actions
. At its core, a reducer takes in a State
and an Action
and uses that to return a new state.
The following highlights the increment
, decrement
, and reset
reducers. As a reminder, Reactor will report an error if the defined reducers don’t correspond to CounterState
and CounterActions
type arguments.
const counter = createReactor<CounterState, CounterActions>({
...,
reducers: {
increment(state, number) {
return { ...state, value: state.value + number };
},
decrement(state, number) {
return { ...state, value: state.value - number };
},
reset(state) {
return { ...state, value: 0 };
},
},
});
Action Creators
Using the State
and Action
type arguments, Reactor is able to generate ActionCreators
. These can be accessed on the actions
property of the returned Reactor
.
const counter = createReactor<CounterState, CounterActions>({
initialState: { value: 0 },
reducers: {
increment(state, number) {
return { ...state, value: state.value + number };
},
},
});
counter.actions.increment(4);
Failing to provide the correct arguments to this action creator will result in a typescript error.
counter.actions.increment();
// Expected 1 arguments, but got 0. ts(2554)
counter.actions.increment("");
// Argument of type 'string' is not assignable to parameter of type 'number | StoredAction<number, Store<CounterState, CounterActions>>'
Action creators can also handle async dispatches.
counter.actions.increment(async () => {
const data = // handle async behaviour...
return data;
})
As before, Reactor will verify that the signature of the returned value, data
in this case, corresponds with the correct type signature.
Reactors
Reactors
are responsible for initializing State
, accepting Reducer
functions, and generating ActionCreators
as specified by the Action
type signature.
interface CounterState {
value: number;
}
interface CounterActions {
increment: never;
}
const counter = createReactor<CounterState, CounterActions>({
initialState: { value: 0 },
reducers: {
increment(state) {
return { ...state, value: state.value + 1 };
},
},
});
Importantly, Reactors
allow you to subscribe to store changes through the reactor.subscribe()
method, access current actions through reactor.actions
and access the current state through reactor.getState()
.
Because reactors are designed to handle individual slices of state (and not represent app state collectively), we also expose a combineReactors
function which returns a CombinedReactor
. Here, we can also pass in ReactorPlugins
to provide additional functionality to our store. The follow example combines a todos
and counter
reactor and initializes a reactorLogger
plugin for providing logs on state changes.
const store = combineReactors({
reactors: { counter, todos },
plugins: [reactorLogger()],
});
Aside from those details, we interact with a CombinedReactor
in the same way we interact with individual Reactors
.