Skip to content

Latest commit

 

History

History
754 lines (607 loc) · 33 KB

README.md

File metadata and controls

754 lines (607 loc) · 33 KB

Reduxology

Reduxology is a library that makes creating Redux-based React applications easier. This library automates and hides much of the boilerplate necessary in typical Redux apps. It also introduces a slightly tweaked model for actions and state, making these abstractions more consistent and user friendly.

In practice, this library is a wrapper for redux and react-redux in your application, replacing the need to use them directly. It is similar to Redux Toolkit, except that it also abstracts React Redux and is a replacement for both libraries.

This README is written with the assumption that you already understand the concepts of React, Redux, and React Redux. If this library takes off, I will invest in creating better documentation.

Installation

Install with npm:

npm install reduxology

If you're a TypeScript user, you don't need to do anything else. This module is written in TypeScript, and includes type definitions in the module itself.

Usage

From a conceptual perspective, using this library is effectively same as using React+Redux, just with a different syntax. So we'll go through the major concepts and describe how they work here.

For a complete example, check out the example in this repo. For a real-world application, check out the client of my Home Lights project.

Actions

There aren't any actual APIs for working with actions in Reduxology or React+Redux, but they're an important concept. In traditional React, an action is an object with a type property that reducers use to determine how to react to an action. In many ways, actions are a lot like standard events in JavaScript with only minor differences in shape and usage.

In Reduxology, actions are modified to look more directly like events in JavaScript. In Reduxology, actions are not an object with a type property, but rather a relationship between a string identifying the type of event, and an arbitrary piece of data representing the rest of the action. Containers and reducers both interact with actions with this same abstraction, as we'll see in the sections on reducers and containers below.

State

State has been remixed in Reduxology so that it looks a lot like actions, for similar reasons. A slice of state, i.e. the part of state created by a single reducer, now has an accompanying slice name. This slice name is directly analogous to an action type, and is used to differentiate one slice of data from another. This is the largest change from typical Redux.

In vanilla Redux, the location of a slice in the store is implicit in the structure of the store for data consumers, and implicit in the combineReducers calls for reducers. In Reduxology, the slice name is used to explicitly define the slice location in both data consumers (containers) and data creators (reducers).

Reducers

To create a reducer, we use the createReducer() function. We pass in two arguments: the slice name, and the data to initialize this reducer with. This function returns an object that we can register action handlers with, which run the actual reducer code. An action handler is very similar to an event handler. An action handler listens for a specific action type, and calls the associated function when the action type is dispatched.

There are two core differences between an action handler and an event listener. Each action type can only have one action handler associated with it per reducer. handle() will throw an exception if you try to register more than one handler for the same action.

This happens because of the second core difference between an action handler and an event listener: action handlers produce changes based on the action that updates state, whereas event listeners don't produce anything. Allowing more than one action handler would make the resulting state change produced by the multiple handlers ambiguous.

Each action handler uses Immer under the hood, which means you don't have to create a complete copy of the state like you need to in vanilla Redux. You can modify properties as you see fit and the rest is taken care of.

// reducers.ts
import { createReducer } from 'reduxology';
import { v4 as uuidv4 } from 'uuid';

const init = {
  appointments: []
};

export const appointmentsReducer = createReducer('Appointments', init)

  .handle('AddAppointment', (state, appointmentData) => {
    state.appointments.push({ ...appointmentData, id: uuidv4() });
  })

  .handle('CancelAppointment', (state, appointmentToCancel) => {
    for (let i = 0; i < state.appointments.length; i++) {
      if (state.appointments[i].id === appointmentToCancel.id) {
        state.appointments.splice(i, 1);
        break;
      }
    }
  });

The returned value from the createReducer is used to register the reducer with Reduxology, as we'll see later.

Note: you do not need to register any handlers to create the reducer. The reducer will exist and return slice data in the init value passed in. This is useful if you want to create a reducer to expose initialization data through Redux that will not change throughout the lifetime of the application

Action Listeners

An action listener is a function that is invoked when a specific action is dispatched, similar to a reducer handler. The key difference between a reducer handler and an action listener is that an action listener cannot change state, but can do operations with side effects. Action listeners are the place to perform API calls, browser navigations, etc. An action listener is virtually indistinguishable from a general JavaScript event listener in practice. Action listeners are registered using the handle() function.

For example, if you wanted to make an API call that fetches an item after a user clicks a button that dispatches a RequestItem action, you could write something like this:

// listeners.ts
import { handle, dispatch } from 'reduxology';

export const requestItemListener = handle('RequestItem', async (getSlice, id) => {
  try {
    const response = await fetch(`/api/items/${id}/`);
    const data = await response.json();
    dispatch('ItemFetched', {id, data});
  } catch (e) {
    dispatch('ItemFetchFailed', id);
  }
});

Action listeners are implemented as Redux middleware under the hood, and inherits several behavioral traits from middleware. Most importantly, action listeners are run before reducer handlers.

Unlike traditional middleware, this function does not provide a mechanism for modifying state. This was done intentionally to keep the API simple and address the most common use case for middleware. This also makes action listeners a safe place to perform side effects without affecting how we reason about state changes.

In some cases, you may need access to other parts of state in your listener in addition to the action data. Each listener is passed getSlice as the second argument, like you get in containers. This allows you to pull in any state you need.

Technical note: Although this action listener is an async function, action listeners are not awaited by Reduxology. This means that the action dispatch is not blocked by the listener, and will continue being dispatched at the first await in the function.

Containers

Containers look quite similar to vanilla React-Redux containers, except that there is a single function call to createContainer() instead of a double call to connect() and the function it returns. The first argument is mapStateToProps, and the second is mapDispatchToProps, same as in React Redux.

A key difference between React-Redux and Reduxology is the argument passed to the mapStateToProps function. In traditional React-Redux this argument is a plain old JavaScript object containing the entire state, typically called state. In Reduxology, this argument is a function, typically called getSlice. Your container can then call getSlice() with a slice name, and it returns that piece of state. At first, this value will be the same as the initialization value passed to createReducer().

// containers.ts
import { createContainer } from 'reduxology';
import { AppComponent } from './components';

export const AppContainer = createContainer(
  (getSlice) => ({
    appointments: getSlice('Appointments').appointments
  }),
  (dispatch) => ({
    addAppointment(time, duration) {
      dispatch('AddAppointment', { time, duration });
    },
    cancelAppointment(appointment) {
      dispatch('CancelAppointment', { appointment });
    }
  }),
  AppComponent
);

App Creation

Reduxology provides a function called createApp() that ties everything together. This function returns a functioning React element you can pass to React's render method. Under the hood, it creates a <Provider> React-Redux element for you and automatically wires the store, reducers, and action listeners into it.

// index.ts
import { render } from 'react-dom';
import { createApp } from 'reduxology';
import { AppContainer } from './containers';
import { appointmentsReducer } from './reducers';
import { requestItemListener } from './listeners';

const app = createApp({
  container: AppContainer,
  reducers: [appointmentsReducer],
  listeners: [requestItemListener]
});

render(
  app,
  document.getElementById('root')
);

Using Existing Redux Middleware

Reduxology supports using existing Redux middleware via the createApp() function detailed above. This is useful if you want to pass in off-the-shelf Redux middleware, such as redux-thunk or redux-saga.

Strict Typing with TypeScript

Reduxology provides a way to strictly type all of your actions and state slices throughout the app. All of the functions we saw in the above examples are actually methods on a class. This class is generic, and takes two generic parameters to define your state and actions, respectively.

I recommend you create a file that is imported by all your files that use Reduxology. Here is the example project's reduxology.ts file with this aliasing:

import { Reduxology } from 'reduxology';
import { Actions, State } from './types';

const reduxology = new Reduxology<State, Actions>();

export const createContainer = reduxology.createContainer;
export const createReducer = reduxology.createReducer;
export const handle = reduxology.handle;
export const createApp = reduxology.createApp;
export const dispatch = reduxology.dispatch;

Note: All of the methods on the Reduxology class are properly bound, so you don't need to worry about binding this manually.

The types.ts file is implemented as:

export interface Appointment {
  id: number;
  time: number;
  duration: number;
}

export interface Actions {
  AddAppointment: {
    time: number;
    duration: number;
  };
  CancelAppointment: Appointment;
}

export interface State {
  Appointments: Appointment[];
}

The State generic parameter is set to an interface. Each property in the interface is the slice name, and the type of that property is the shape of the slice data. Similarly, the Actions generic parameter is set to an interface as well. Each property in the interface is the action type, and the type of that property is the data associated with that action type or void if there is no data.

With these generics in place, you now get strict type checking in your createReducer, reducer.handle, handle, and createContainer functions. Any attempt to access an invalid slice name or action type will throw an error. Additionally, the data associated with those calls is strictly typed too. To see this in action, let's consider some examples.

This compiles properly:

const addAppointmentListener = handle(
  'AddAppointment',
  ({ time, duration }) => console.log(time, duration)
);

This fails to compile because Incorrect isn't a known action.

const addAppointmentListener = handle(
  'Incorrect',
  ({ time, duration }) => console.log(time, duration)
);

This also fails to compile, because incorrect isn't part of the AddAppointment action:

const addAppointmentListener = handle(
  'AddAppointment',
  ({ incorrect }) => console.log(incorrect)
);

The implementation for this type checking is based on Brian Terlsen's fantastic strict-event-emitter-types library.

Motivation

I've written many React apps, some small, some large. I've also taught React to a number of folks. Many developers, especially junior developers, get tripped up on understanding what all the pieces do, and how to connect them together. I've been thinking hard on this problem for a while, and I think I've figured out where the confusion comes from.

When we talk about React+Redux and separation of concerns, we tend to talk about the data flow separation between parts of a React+Redux app, specifically how data flows one way. And React+Redux is very good at constraining data flow such that it's easy to test and reason about.

But what about data dependency, e.g. how are multiple pieces of the app dependant on the shape of a piece of data, and how is that shape defined? This is where vanilla Redux stumbles, in my opinion. To illustrate this, let's discuss the three core concepts in React+Redux: reducers, containers, and actions.

Actions

Let's start by talking about actions. In a typical Redux application, we use action creators to, well, create an action. These are effectively helper functions that take in specific parameters and create an action. An example action creator for the AddAppointment action illustrated above would look like:

function createAddAppointmentAction(time, duration) {
  return {
    type: 'AddAppointment',
    appointment: {
      time,
      duration
    }
  };
}

These are a nice encapsulation that makes it easier to create actions, which happens in a container. In addition, the container that's creating the action doesn't need to know the shape of the action object, a nice encapsulation!

But what about consuming these actions, which happens in reducers? There is no equivalent helper to consume an action in a reducer. This undermines the value of the abstraction created by the action creator because there is still a data dependency on the output of an action creator inside the reducer. The shape of this data is only abstracted in half our code!

Reducers and Containers

Next, let's talk about reducers and containers. At a high level, reducers and containers are mirror images of each other. A reducer consumes an action and produces state, while a container consumes state and produces actions (indirectly through a child component). This is a nice level of symmetry in the design.

Reducers take in the application's current state plus an action, and produce a new state. One of the nice ways reducers are encapsulated is that each reducer is only responsible for a subsection of state, called a slice in Redux parlance. Reducers don't need to know anything about the state in the rest of the store. This is really great, and one of the things I love most about reducers. Below is an example:

import { v4 as uuidv4 } from 'uuid';

const appointmentsReducer = (state, action) => {
  switch (action.type) {
    case 'AddAppointment':
      const newAppointment = {
        ...action.appointment,
        id: uuidv4()
      }
      return {
        ...state,
        appointments: [ ...state.appointments, newAppointment ]
      };
    case 'CancelAppointment':
      for (let i = 0; i < state.appointments.length; i++) {
        if (state.appointments[i].id === appointmentToCancel.id) {
          state = {
            ...state,
            appointments: [ ...appointments ]
          };
          state.appointments.splice(i, 1);
        }
      }
      return state;
  }
};

combineReducers({
  appointments: appointmentsReducer,
  ...
});

This approach breaks down in one subtle way though, and that has to do with where this state exists in the store. The state has to exist somewhere, so that's not an issue in and of itself. The issue has to do with how the location in the store is expressed.

Reducers define this location in how they are combined together with the combineReducers calls. The location ends up being implicit, and one of the niceties of this is that you can rename a slice that a reducer operates on without modifying the reducer itself, just the combineReducers call.

Similar to action creators, we have a nice encapsulation here. But what about those that consume this state, i.e. containers? There is no equivalent mechanism to abstract the state from the location of that state, as we can see in the related container below:

function mapStateToProps(appState) {
  return {
     // Note the full path to state shows up here. Knowing which "path" to use
     // requires knowledge of how the various `combineReducers` calls are scaffolded
    appointments: appState.appointments.appointments
  };
}

function mapDispatchToProps(dispatch) {
  return {
    addAppointment(appointment) {
      dispatch(createAddAppointmentAction(appointment));
    },
    cancelAppointment(appointment) {
      dispatch(createCancelAppointmentAction(appointment));
    }
  };
}

const AppContainer = connect(mapStateToProps, mapDispatchToProps)(AppComponent);

Worse, the way this location is defined cannot be reused between reducers and containers. You have to manually verify the paths are correct separately in containers and combineReducers calls by hand, even if you're using TypeScript. Indeed, this is the biggest struggle I've had with TypeScript and React+Redux, because I had to effectively duplicate type information manually between these two parts of the application.

Conclusion

After reflecting on these issues, I realized that we have an issue with the consistency of our abstractions in vanilla React+Redux. The way that actions are created vs consumed is different, with the container side being well abstracted and the reducer side not. The way that state is created vs consumed is the same but flipped, with the reducer side being well abstracted and the container side not. This imbalance diminishes the usefulness of these abstractions, and in my experience has led to confusion among developers, especially junior developers. This partial abstraction makes a lot of the code look like magic, and can lead to not understanding why some things require manual coding and others don't.

Thinking through the architecture of Redux more, there is another bit of implicit symmetry: state and actions are both data that flows through the system. They do represent very different types of data, so these are not things that should be combined. But, it is an observation that has influenced the API design of Reduxology.

In addition to Redux' somewhat muddy view of data dependencies vs data flow, there's also just a lot of stuff you have to do to connect all the pieces together. When we look at a dependency graph of a codebase based on import statements, the unidirectional flow of data tends to be obscured by all the scaffolding around it.

Reduxology aims to address all of these issues to varying degrees, while keeping all the benefits of React+Redux that makes them such an amazing way to create UIs.

API

Note: Any property or argument with a "?" after it is optional

createContainer(mapStateToProps, mapDispatchToProps, component) => React Redux Container

Creates a container, in the React Redux sense, for use as a React component.

Arguments:

Argument Type Description
mapStateToProps(getSlice, ownProps?) Function A mapStateToProps function, analogous to the first argument passed to connect() in React Redux. This function takes in the current state, and returns the props for the React component passed to createContainer
Arguments:
Argument Type Description
getSlice(sliceName) Function Gets a state slice from the global state object
Arguments:
Argument Type Description
getSlice Function Gets a state slice from the global state object

Return Value:
The requested state slice value.

Throws:
An exception is thrown if an invalid state slice name is passed in.
ownProps Object The properties passed to the container from its parent component

Return Value:
The state-derived props to be passed to the React component this container is wrapping.
mapDispatchToProps(dispatch, ownProps?) Function A mapDispatchToProps function, analogous to the second argument passed to connect() in React Redux. The dispatch argument passed to this function is different from the dispatch argument in React Redux mapDispatchToProps.
Arguments:
Argument Type Description
dispatch(type, data?) Function See the global dispatch function for a description of how this argument is used.
ownProps Object The properties passed to the container from its parent component

Return Value:
Dispatch-derived props to be passed to the React component this container is wrapping.
component React Component A React component to wrap in this container, analogous to the component argument passed to the function returned from connect() in React Redux

Return value:

A React Redux container, which can be used directly as a React component. This is the same value returned from React Redux' connect()() functions. See the React Redux documentation for more information.

createReducer(sliceName, initialData) => Reducer

Creates a reducer.

Arguments:

Argument Type Description
sliceName String The slide name that this reducer will reduce data to
initialData Any The data to initialize this reducer slice with. It can be any value of any data type.

Return value:

A reducer object that you can use to connect action handlers to. See the Reducer documentation below for details.

Reducer#handle(actionType, handler) => Reducer

Adds an action handler to the reducer. Note: there can only be one handler registered for a given action type at a time.

Arguments:

Argument Type Description
actionType string The user-defined action type to handle
handler(state, action?) Function The handler to invoke when an action specified by actionType is dispatched.
Arguments:
Argument Type Description
state any The current state slice. This value is wrapped in a call to Immer's produce() function, and can be mutated safely.
action? any The action data that was passed to the dispatch function.

Return Value:
None.

Return value:

Returns the reducer that handle() was called on, so that you can chain multiple handle() calls together.

Throws:

This function will throw an exception if you try to register a handler to an action type that already has a handler registered to it.

Return value:

A boolean value indicating whether or not a handler has been registered for the given action type.

handle(actionType, listener)

Creates a listener for the given action. This method is useful for connecting API calls to actions.

Arguments:

Argument Type Description
actionType String The type of action to listen for
listener(getSlice, data) any A listener that will receive the action data.
Arguments:
Argument Type Description
getSlice(sliceName) Function Gets a state slice from the global state object
data any or undefined Data associated with the action, as passed to the dispatch function.

Return Value:
None.

Return value:

None.

createApp(options) => React Component

Creates a root-level React component to be passed to React's render() method. This function creates the store for you and attaches it to a React Redux <Provider> component, attaches the supplied container as the one and only child to it, and wires in the supplied reducers and listeners.

Options:

Property Type Description
container React Redux Container The container to attach, as returned from createContainer()
reducers? Array of Reducers An array of reducers to wire up, created via createReducer()
listeners? Array of Listeners An array of listeners to wire up, created via handle()
middleware? Array of Redux Middleware Any additional Redux middleware you'd like to attach to the Redux instance, e.g. redux-thunk or redux-saga. These middleware can be passed in as-is without modification. They are passed to the Redux `applyMiddleware` method directly, so there's no need to call `applyMiddleware` yourself.

Note: the value for store, next, and action used by the middleware are the raw under-the-hood variants, not the Reduxology variants. I soon hope to implement some helper methods you can use to convert actions and the store to the Reduxology variants.

Return value:

A React component containing the <Provider> component and supplied container.

dispatch(actionType, data?)

Dispatches an action in a global context. This function is useful if you have actions that are not generated by the UI, such as receiving a Web Socket message from a server.

Note: this function should not be used in the place of the dispatch parameter passed to a container's mapDispatchToProps.

Arguments:

Argument Type Description
actionType String The type of action to dispatch
data? any Data associated with the action

Return value:

None.

new Reduxology()

Creates a new Reduxology instance which will have its own store associated with it. Each of the previous methods described in the API section are present on the Reduxology instance. All of the previous methods are, in fact, part of a Reduxology instance that is created for you automatically behind the scenes.

License

MIT License

Copyright (c) Bryan Hughes bryan@nebri.us

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.