Reactables: Reactive State Management for any UI Framework.
Dave Lai
Posted on October 16, 2024
Prerequisite - This article is intended for developers that have a basic understanding of RxJS.
TL;DR: Getting JavaScript fatigue from too many state management API's across different UI frameworks?
See how Reactables can solve the vast majority of your state management needs in any UI framework.
Introduction
Ever get JavaScript fatigue and wonder: why do we need so many APIs to model our state logic?
The fragmented state of state management has had some unfortunate consequences:
Using different API's has hampered our ability to reuse and extend similar logic in our applications. Logic for local component state often can't be reused for global state and vice versa. This is because the APIs for each case are not compatible and often follow different programming paradigms (i.e. imperative vs declarative)
We have also tightly coupled our state logic with UI frameworks, creating an unnecessary divide amongst the developer community - trying to solve the same state management problems with their UI framework specific APIs.
What if we had one core API for modelling state that is flexible enough to handle the vast majority of use cases and work with any frontend framework?
These reflections led me to create Reactables - a reactive state management solution powered by RxJS.
This article will be an introduction to Reactables covering the following topics:
- The Reactable Interface
- Creating a Reactable primitive and binding it to UI components
- Data fetching with a Reactable
- Composition with Reactables for more complex states
- Global State with Reactables
The Reactable Interface
const [state$, actions] = RxToggle();
The Reactable Interface is a tuple where its first item is a RxJS Observable that emits new state objects as updates occur. The second item is an object of action methods the UI can call to invoke state changes.
The state logic is encapsulated in the Reactable and separated from presentation concerns. The UI layer calls the desired action and the Reactable will react and emit the new state.
Any UI framework (or vanilla JS) can subscribe to the state observable and update the view.
The Reactable Primitive
A Reactable primitive is the basic building block for modelling your state.
It can be used alone or combined with other primitives to form more complex Reactables as the state of your component/feature/application grows.
Hub and Store
Internally, a Reactable primitive has a hub and store. Both work like a flux pattern where actions are dispatched through the hub to a store where state updates occur.
The hub is also responsible for handling side effects such as API requests which will be covered later.
You can create a Reactable primitive with @reactables/core
's RxBuilder.
Below is an example for creating a Reactable that toggles a boolean state.
Install RxJS and Reactables core API
npm i rxjs @reactables/core
import { RxBuilder, Reactable } from '@reactables/core';
type ToggleState = boolean;
type ToggleActions = {
toggleOn: () => void;
toggleOff: () => void;
toggle: () => void;
};
export const RxToggle = (
initialState = false
): Reactable<ToggleState, ToggleActions> =>
RxBuilder({
initialState,
reducers: {
toggleOn: () => true,
toggleOff: () => false,
toggle: (state: ToggleState) => !state,
},
});
See full example on StackBlitz for: React | Angular | VanillaJS
You can then bind RxToggle
to the view. Below is an example of binding to a React component with Reactable's useReactable
hook.
import { RxToggle } from './RxToggle';
import { useReactable } from '@reactables/react';
function App() {
const [toggleState, actions] = useReactable(RxToggle);
const { toggleOn, toggleOff, toggle } = actions;
return (
<>
<h5>Reactable Toggle</h5>
Toggle is: {toggleState ? 'On' : 'Off'}
<br />
<button onClick={toggleOn}>Toggle On</button>
<button onClick={toggleOff}>Toggle Off</button>
<button onClick={toggle}>Toggle</button>
</>
);
}
export default App;
Data fetching with a Reactable
Reactables handle side effects such as API requests with effects
.
When an action is dispatched and a side effect is needed, a replayed action is sent through an effect stream to execute the side effect.
Responses are then mapped into actions and relayed to the store.
Effects
are expressed as RxJS Operator Functions allowing you to make full use of RxJS for customizing your asynchronous logic.
You can add any number of effects for the action/reducers defined in your Reactable. In the following example a Reactable for fetching data is created and an effect is added for the fetch
action/reducer.
import { RxBuilder, Reactable } from '@reactables/core';
import DataService from './data-service';
import { from, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
export type FetchDataState = {
loading: boolean;
success: boolean;
data: string | null;
error: unknown;
};
const initialState: FetchDataState = {
loading: false,
success: false,
data: null,
error: null,
};
export type FetchDataActions = {
fetch: () => void;
};
export type FetchDataReactable = Reactable<FetchDataState, FetchDataActions>;
export const RxFetchData = ({
dataService,
}: {
dataService: DataService;
}): FetchDataReactable =>
RxBuilder({
initialState,
reducers: {
fetch: {
reducer: (state) => ({ ...state, loading: true }),
effects: [
(action$) =>
action$.pipe(switchMap(() => from(dataService.fetchData()))).pipe(
map((response) => ({ type: 'fetchSuccess', payload: response })),
catchError((err: unknown) =>
of({ type: 'fetchFailure', payload: true })
)
),
],
},
fetchSuccess: (state, action) => ({
...state,
success: true,
loading: false,
data: action.payload as string,
error: null,
}),
fetchFailure: (state, action) => ({
...state,
loading: false,
error: action.payload,
success: false,
}),
},
});
See full example on StackBlitz for: React | Angular
Binding to a React component below.
import { useReactable } from '@reactables/react';
import DataService from './data-service';
import { RxFetchData } from './RxFetchData';
import './App.css';
function App() {
const [state, actions] = useReactable(RxFetchData, {
dataService: new DataService(),
});
if (!state) return;
const { loading, data } = state;
return (
<>
<div>
{data && <span>{data}</span>}
<br />
<button onClick={actions.fetch}>Fetch Data!</button>
<br />
{loading && <span>Fetching...</span>}
</div>
</>
);
}
export default App;
Composition, Reactive Programming and Unidirectional Flow
Thus far we have only created Reactable primitives with the RxBuilder factory function.
You can combine any number of Reactables together to form a new one.
Two primary use cases for this approach (not mutually exclusive):
You wish to create a Reactable that reuses functionality from other Reactables.
One part of your state needs to react to changes of another part.
Reactables follows a reactive programming style where a reactable's sources of change are explicit in its declaration.
This results in a unidirectional flow of actions making state changes highly predictable.
As an example, consider a naive search that filter's hotels based on smokingAllowed
and petsAllowed
. Using RxToggle
and a slightly modified RxFetchData
from the previous examples, you can combine them and implement the search.
Starting with the toggle filter controls for smokingAllowed
and petsAllowed
. You can create a Reactable with the following state and actions.
export type SearchControlsState = {
smokingAllowed: ToggleState; // boolean
petsAllowed: ToggleState; // boolean
};
export type SearchControlsActions = {
toggleSmokingAllowed: () => void;
togglePetsAllowed: () => void;
};
You can initialize an RxToggle
for each filter control and use RxJS's combineLatest
function to combine the state observables together to create RxSearchControls
.
import { combineLatest } from 'rxjs';
...
export const RxSearchControls = (): Reactable<
SearchControlsState,
SearchControlsActions
> => {
const [smokingAllowed$, { toggle: toggleSmokingAllowed }] = RxToggle();
const [petsAllowed$, { toggle: togglePetsAllowed }] = RxToggle();
// Combine state
const state$ = combineLatest({
smokingAllowed: smokingAllowed$,
petsAllowed: petsAllowed$,
});
// Combine actions
const actions = {
toggleSmokingAllowed,
togglePetsAllowed,
};
return [state$, actions];
};
Next, create a RxHotelSearch
Reactable that includes RxSearchControls
and RxFetchData
.
RxFetchData
can be updated from the previous example to include a sources
option. Reactables have the option to listen to any number of source observables emitting actions so they can react to them.
//...
export const RxFetchData = ({
dataService,
sources,
}: {
dataService: DataService;
sources: Observable<Action<unknown>>[]
}): FetchDataReactable =>
RxBuilder({
initialState,
sources, // Add sources
reducers: {
// ...
},
});
When there is a state change in RxSearchControls
, RxFetchData
will react and fetch data to perform the search.
You can pipe the state observable from RxSearchControls
and map it to a fetch
action. Then declare this piped observable, fetchOnSearchChange$
, as a source when initializing RxFetchData
.
import { Reactable } from '@reactables/core';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import {
RxSearchControls,
SearchControlsState,
SearchControlsActions,
} from './RxSearchControls';
import { RxFetchData, FetchDataState } from './RxFetchData';
import HotelService from '../hotel-service';
type HotelSearchState = {
controls: SearchControlsState;
searchResult: FetchDataState;
};
type HotelSearchActions = SearchControlsActions;
export const RxHotelSearch = ({
hotelService,
}: {
hotelService: HotelService;
}): Reactable<HotelSearchState, HotelSearchActions> => {
const [searchControls$, searchControlActions] = RxSearchControls();
// Create a source observable that will tell RxFetchData to fetch when control changes.
const fetchOnSearchChange$ = searchControls$.pipe(
map((search) => ({ type: 'fetch', payload: search }))
);
const [searchResult$] = RxFetchData({
dataService: hotelService,
sources: [fetchOnSearchChange$], // Add source observable
});
const state$ = combineLatest({
controls: searchControls$,
searchResult: searchResult$,
});
const actions = searchControlActions;
return [state$, actions];
};
Then use combineLatest
function again to to give us our combined state observable.
See full example on StackBlitz for: React | Angular
Binding to a React component
import { useReactable } from '@reactables/react';
import HotelService from './hotel-service';
import { RxHotelSearch } from './Rx/RxHotelSearch';
import './App.css';
function App() {
const [state, actions] = useReactable(RxHotelSearch, {
hotelService: new HotelService(),
});
if (!state) return;
const {
controls: { smokingAllowed, petsAllowed },
searchResult: { loading, data },
} = state;
return (
<>
<div>
<br />
<button onClick={actions.toggleSmokingAllowed}>
Smoking Allowed : {smokingAllowed ? 'Yes' : 'No'}{' '}
</button>
<br />
<br />
<button onClick={actions.togglePetsAllowed}>
Pets Allowed : {petsAllowed ? 'Yes' : 'No'}{' '}
</button>
<br />
{loading && 'Searching...'}
<br />
{data && data}
</div>
</>
);
}
export default App;
Global State with Reactables
Your global state can be managed by one Reactable. This Reactable can be created with RxBuilder or via composition.
Reactables are unopinionated on how they are stored and accessed for global state management.
In React you can use a Context
or prop drilling. @reactables/react
package has a StoreProvider
component if you want to use a context to store your reactable. The state can then be accessed with the useAppStore
hook.
In Angular, initializing your Reactable in a service provided in root
is an easy choice.
You can use the APIs available in your framework for storing Reactable(s) in the global scope.
Decorate Reactable with storeValue
By default, the state observable from a Reactable is just an Observable
. It does not hold a value and only emits a new state object when an action is invoked.
When using a Reactable for managing global state, it needs to be decorated with the storeValue
decorator which extends the Reactable to return a ReplaySubject
instead of the default state Observable
. This ensures subsequent subscriptions from UI components will always receive the latest value.
Example:
const [
state$, // state$ is now a ReplaySubject
actions
] = storeValue(RxToggle());
Conclusion
This has been an introduction to Reactables API where we covered a range of state management examples.
Check out the documentation for more examples including how Reactables can be used to manage forms!
Reactables hope to provide a tool for solving state management problems in a unified way - for developers from all UI frameworks.
Posted on October 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.