rascl

Redux API State Caching Layer

Usage no npm install needed!

<script type="module">
  import rascl from 'https://cdn.skypack.dev/rascl';
</script>

README

RASCL State Diagram RASCL: Redux API State Caching Layer

npm Codecov GitHub Workflow Status (branch) GitHub Workflow Status (branch)

RASCL is an opinionated library that creates "zero boilerplate" bridges between API clients and Redux.

Trying to follow established best practices for Redux often results in repetitious code. Because this type of code is tedious to write and time-consuming to maintain, it's also a frequent source of "copy/paste" errors.

Libraries like redux-actions and redux-act already reduce some of this boilerplate, but RASCL goes further and removes it all.

Given a map of API calls, RASCL can generate every part of a complete Redux and Redux-Saga setup:

import * as MyAPI from "./API";

const { createRASCL } = await import("rascl");

export const {
  types,         // String constants for action types
  actions,       // Action creator functions
  initialState,  // Initial Redux store state
  handlers,      // Action handlers for state updates
  watchers,      // Sagas to respond to each type of action
  reducer,       // A root reducer
  rootSaga,      // A root saga
} = createRASCL(MyAPI);

Once RASCL is invoked, an application only needs to do two things:

  1. Dispatch Redux actions to indirectly make API requests
  2. Use selector functions to indirectly access the results


Installation

npm i -S rascl

or

yarn add rascl

redux-saga and ky are optional dependencies. Both are highly recommended, but not strictly necessary for RASCL to function.

⚠︎ NOTE: When used with TypeScript, RASCL's typings require TypeScript version 4.2 or later due to a dependency on Template Literal Types.


How to Use RASCL

RASCL is designed to fit into any existing Redux implementation.

This simple example assumes the following directory structure:

src
 ┣ api
 ┃  ┗ MyAPI.ts
 ┗ redux
    ┣ RASCL.ts
    ┣ reducer.ts
    ┗ store.ts

The API file should export either an object, or individual named exports that can be imported with a wildcard. RASCL uses the names of these functions as the basis for all the action types and function signatures

src/api/MyAPI.ts

export const getSomething = () =>
  fetch("https://jsonplaceholder.typicode.com/posts/1")
    .then((response) => response.json());

It's a good idea to call createRASCL in a dedicated module and export the results.

src/redux/RASCL.ts

import * as MyAPI from "../api/MyAPI";
const { createRASCL } = await import("rascl");

export const {
  types,
  actions,
  initialState,
  handlers,
  watchers,
  reducer,
  rootSaga,
} = createRASCL(MyAPI);

Then, add the reducer into combineReducers:

src/redux/reducer.ts

import { combineReducers } from "redux";

import { reducer } from "./RASCL";

const rootReducer = combineReducers({
  RASCL: reducer,
});

export default rootReducer;

The setup for the application's store is entirely conventional, the root reducer and root saga are each passed to their respective handlers.

src/redux/store.ts

import { createStore, applyMiddleware } from "redux";
import createSagaMiddleware from "redux-saga";

import rootReducer from "./reducer";
import { rootSaga } from "./RASCL";

export const sagaMiddleware = createSagaMiddleware();
export const enhancer = applyMiddleware(sagaMiddleware);
export const store = createStore(rootReducer, enhancer);

sagaMiddleware.run(rootSaga);

How RASCL Works

RASCL tracks the "lifecycle" of each API call as a finite state machine. As the API call "moves" through the lifecycle, each stage dispatches a Redux action containing the relevant data.

RASCL State Diagram An API endpoint's state transitions

The data contained in each action is cached in the Redux store, from the initial request parameters through to the successful API result (or an error). This makes logging and debugging extremely straightforward, and upholds a core principle of RASCL: All the data is always available.


Starting Conditions

INITIAL
This is the starting state for all endpoints. An endpoint at `INITIAL` has not been used. No API calls have been made, no actions have been dispatched, and all data fields will still be null.

Making Requests

ENQUEUE (Optional)
The optional `ENQUEUE` state allows for common utility patterns. An "offline-first" webapp may want to allow enqueueing multiple requests while offline, or a modal login window may be rendered over the application, which has UI logic that makes API calls as soon as possible after rendering. In either case, `ENQUEUE` allows a developer to create preconditions for certain calls, for instance to say "only make this call once the device is online and the user has valid credentials".
REQUEST
An endpoint is set to the `REQUEST` state after the API request has been made, but before any response has come back. This will set `isFetching: true`, which is useful for triggering spinners or blocking user actions while awaiting data.

Handling Data

SUCCESS
Indicates a 2XX response from the API. May or may not include a body.

Recovering From Errors

FAILURE
Usually indicates a 4XX response from the API.
MISTAKE
Indicates a 5XX response from the API.

Resolving Warnings

OFFLINE
Indicates that the device is offline, or otherwise has no internet connection.
TIMEOUT
Indicates that the request was sent, but that the response didn't arrive in a specific timeframe.

Actions and Stored Data

Endpoint state objects all have the same shape, consisting of metadata, used to determine "where" in the state diagram the endpoint currently is, and state data, used to cache the last result of a given type.

The initial state for every endpoint looks like this:

{
  /**
   * STATE DATA
   * Parameters for requests, API results, errors, etc.
   */
  enqueue: null,
  request: null,
  success: null,
  failure: null,
  mistake: null,
  timeout: null,
  offline: null,

  /**
   * METADATA
   * Location in the finite state machine, helpers for
   * showing how old the last result is, are we
   * currently awaiting data, etc. This helps with common
   * patterns like "show a spinner while waiting for
   * results from the API".
   */
  isFetching: false,
  lastUpdate: null,
  lastResult: null,
}

Each step in the lifecycle dispatches a Redux action containing the exact relevant data for that step, in the form of a Flux Standard Action. The payload of each action is recorded in the Redux store.

For example, a call that completes successfully will have an entry in the store that looks something like this:

{
  enqueue: {/* Original parameters for the API call */},
  request: {/* Original parameters plus any authentication */},
  success: {/* Parsed response or other data from the API */},
  failure: null,
  mistake: null,
  timeout: null,
  offline: null,
  isFetching: false,
  lastUpdate: 1626261600000,
  lastResult: "success",
}

Motivation

Redux is fundamentally a way to globalize state. This flexability is extremely helpful when implementing complex and unique data models for an application, but having "complex" and "unique" API interactions can complicate and slow down development, especially on larger teams.

A common problem with storing API responses in Redux is that - given enough developers - different people will duplicate or extend Redux boilerplate according to their current focus. It often seems efficient or "cleaner" to store only a small portion of a response, knowing or assuming that the remainder is (currently) unused.

Strawman Example

Let's imagine that Alice and Bryce are working on a new application against an existing API.

Alice starts a feature "As a user, I should see my first name in the account menu". She looks at the API documentation, and sees that GET /user/profile returns an object containing { firstName: string }. She creates an entry in the state tree for reducers/user.js, storing firstName so that it's accessible as state.user.firstName

In the real world, it's hardly likely that a user's first name is the only data from /user/profile this application will care about, but it serves as a useful framing device for considering what fields in a larger response body might be omitted or ignored - maybe the user profile contains a large array of that user's recent events, and this application doesn't currently use them.

Next, Bryce starts a feature "As a user, I should see my profile picture in the account menu". The image URL is contained in the same GET user/profile response, but now Bryce has to understand and discover all of the decisions and data muxing done by Alice. If they misunderstand Alice's intent, or overlook her implementation entirely, the outcome may be a duplication of effort, or create multiple handlers for the same API call.

Worse still, if Alice created Redux actions and reducers around a action type of UPDATE_USER_FIRST_NAME, Bryce may have no choice but to either create more boilerplate for UPDATE_USER_PROFILE_IMAGE, or refactor the existing code to UPDATE_USER_PROFILE.

The first option matches the existing pattern, but worsens the technical debt, and doesn't address the fundamental problem.

The second option addresses the technical debt, but may introduce unexpected behavior or take longer to implement because Bryce has to find every place the old code was in use and update it for the new data model and action name.


Related Concepts


Inspiration