DEV Community

Cover image for Reactables: Reactive State Management for any UI Framework.
Dave Lai
Dave Lai

Posted on • Edited on

Reactables: Reactive State Management for any UI Framework.

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.


State Management

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();
Enter fullscreen mode Exit fullscreen mode

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.


Hub and Store

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
Enter fullscreen mode Exit fullscreen mode
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,
    },
  });
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

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,
      }),
    },
  });
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.


Reactable Composition

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;
};
Enter fullscreen mode Exit fullscreen mode

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];
};

Enter fullscreen mode Exit fullscreen mode

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: {
      // ...
    },
  });

Enter fullscreen mode Exit fullscreen mode

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];
};

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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());

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)