npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

redux-keto

v0.3.5

Published

A tool for building fat reducers

Downloads

651

Readme

redux-keto

A tool for building fat reducers

npm downloads

The Redux architecture works best when the reducers contain as much business logic as possible. Doing this in practice is hard, though, since reducers can't pass values between each other.

This library provides a way to build "fat reducers", which take an extra next argument in addition to the normal state and action arguments. Fat reducers use this extra parameter to pass values between each other in a fully-reactive, auto-updating way.

Fat reducers work seamlessly with normal reducers, so there are no big changes to the way Redux works. Just use fat reducers wherever they make sense.

Table of contents

  1. Example
  2. Derived state
  3. Customizing next
    1. Reducer lists
    2. Isolated reducers
  4. Implementation details

Example

Suppose an app has two pieces of state, a counter and a user-settable maxCount. The counter should never exceed maxCount, so it also needs update itself whenever maxCount changes.

Using redux-keto, the maxCount reducer can be perfectly normal:

function maxCount (state = 0, action) {
  return action.type === 'CHANGE_MAX_COUNT' ? action.payload : state
}

The counter reducer is only a little more complicated, since it needs to consider the maxCount state:

function counter (state = 0, action, next) {
  return Math.min(
    next.maxCount,
    action.type === 'INCREMENT' ? state + action.payload : state
  )
}

Finally, the buildReducer function from redux-keto combines these two reducers into one:

import { buildReducer } from 'redux-keto'

export const rootReducer = buildReducer({ maxCount, counter })

That's it! Compared to the alternatives, this is incredibly simple.

Everything is fully reactive, since the next parameter reflects the upcoming state of each reducer. In other words, next.maxCount contains the result of running the maxCount reducer on the current action. This means that dispatching a CHANGE_MAX_COUNT action will automatically update the counter without any extra work. The counter reducer always keeps itself up to date by checking the new maxCount state on each action.

Derived state

To derive a value from some existing state, just create a fat reducer that ignores its state and action parameters:

function countIsEven (state, action, next) {
  return next.counter % 2 === 0
}

Now countIsEven will stay in sync with the counter no matter what happens. Because this is just a normal reducer, it will also appear in next so other reducers can access it.

To optimize cases where the state hasn't changed, fat reducers also receive a prev parameter, which holds the state before the current action. The reducer can compare the two states to see if it needs to do any work:

function countIsOdd (state, action, next, prev) {
  if (next.counter === prev.counter) return state

  return next.counter % 2 === 1
}

Now the countIsOdd calculation will only run when the counter actually changes.

To automate this, redux-keto provides a memoizeReducer function. This function works a lot like the reselect library, but for reducers:

const isOdd = memoizeReducer(
  next => next.counter,
  counter => counter % 2 === 1
)

The last parameter to memoizeReducer is the actual calculation. All the previous parameters are functions that grab items out of the next state. If all the items are the equal (===) to their previous values, memoizeReducer just returns the previous state. Otherwise, memoizeReducer runs the calculation with the items as parameters.

Customizing next

By default, buildReducer passes next through to its children unchanged. If buildReducer doesn't receive a next parameter, it initializes next with its own children. This is why the initial example works—the top-level buildReducer doesn't receive a next parameter, so it sets up a next object with the future maxCount and counter states as properties. This also means that if buildReducer happens to be the top-most reducer in the Redux store, next will match the Redux state tree returned by getState().

To customize this behavior, just pass a makeNext function as the second parameter to buildReducer:

counterState = buildReducer(
  buildReducer({ maxCount, counter }),
  (next, children, id) => children
}

The makeNext function accepts three parameters, next, children, and id. The next parameter is just whatever was passed to buildReducer, the children parameter holds the future buildReducer state, and id is the current reducer's name.

In this example, makeNext just returns the children unconditionally. This means that the counter reducer can always refer to next.maxCount, no matter where it is located in the Redux state tree.

Reducer lists

Applications often manage lists of things. For example, a chat platform might manage multiple conversations, each with its own state. To handle cases like this, redux-keto provides a mapReducer function:

import { mapReducer } from 'redux-keto'

const chatsById = mapReducer(
  chatReducer,

  // The list of ids:
  next => next.activeChatIds,

  // Set up `next` for each child:
  (next, children, id) => ({
    chatId: id,
    root: next,
    get self () {
      return children[id]
    }
  })
)

The first mapReducer parameter is the reducer to replicate, and the second parameter returns a list of ids. There will be one chatReducer for each unique id (duplicates are ignored).

The final parameter is a makeNext function, just like the one buildReducer accepts. If this isn't provided, mapReducer will create a default next parameter with the following properties:

  • id - the current child's id.
  • root - the next parameter passed to mapReducer, or children if next is undefined.
  • self - the current child's future state.

If you want to replicate this behavior yourself, be sure to implement self using a getter function, as shown above. Otherwise, you may get a circular reference error.

Isolated reducers

To customize both the actions and next parameter going into an individual reducer, use filterReducer. This is especially useful with mapReducer, since it allows each child reducer to act like its own stand-alone sub-store:

const chatReducer = filterReducer(
  chatSubsystem,

  // Filter the actions (receives the outer `next` parameter):
  (action, next) => {
    if (action.payload.chatId === next.chatId) {
      return action
    }
    if (action.type === 'LOGIN') {
      return { type: 'CHAT_INIT'}
    }
  },

  // Adjust `next` for the child:
  next => ({ settings: next.root.chatSettings })
)

In this example, the chatReducer will only receive actions where the chatId matches its own chatId. It will also receive a CHAT_INIT message when the outer system receives a LOGIN action.

Implementation details

Circular dependencies

The buildReducer and mapReducer functions create the illusion of time travel by passing their own future state into their children. They achieve this magic using memoized lazy evaluation. Each property of the next object is actually a getter that calls the corresponding child reducer to calculate the new state on the spot (unless the reducer has already run).

This means that circular dependencies will not work. If a reducer tries to read its own output, even indirectly, it will fail with a ReferenceError.

Default state

The first time a reducer runs, it has no previous state. On the other hand, allowing prev to just be undefined would make writing reducers much more difficult. To solve this, redux-keto looks for a property called defaultState on each reducer function. If it finds one, it uses that as the initial state and builds the prev parameter based on that. This allows prev to have a useful tree structure, even on the first run.