Redux Without Boilerplate

Setting State in Redux Without Action Boilerplate

Recently, I've been thinking a fair bit about Redux and front end application state management in general. I do really like Redux and I think it's changed the way a lot of us think about state management. That said, many people, myself included, complain about the verbosity and amount of boilerplate required to implement even the simplest of functionality. In this post I'll explore one method I've come up with to reduce some redux boilerplate.

Disclaimer

I want to be very clear before I begin that this idea is hot off the presses of my mind. That is to say I'm not entirely sure if this is a good pattern or what kind of issues this could create in a larger application.

Unrelatedly, I'll be using Typescript for everything here. It's mostly inconsequential besides the last section regarding type safety.

So with that out of the way, let's dive into it.

Boilerplate Hell

Consider the things we do in Redux that have a high LOC/functional simplicity ratio, in other words, things that make you say "I have to write so much to do something so simple". Making simple updates to Redux state is definitely up there in this regard.

You need to make a new action type, an action creator, if you're using Typescript you may need to define a new type for the action, and finally, you need to add a new case to a reducer to handle the new action that combines the new value into the global state. This typically requires touching two or three separate files in addition to the component you're working in.

On the other hand, setting local component state is extremely easy in React:

this.setState({ title: "Reducks" })

Paring Down Our Code

I think we can get (mostly) to the this.setState level of simplicity with a relatively small amount of code and a little bit of library help. I want to create a reducer that handles a single generalized action type that holds the path to the field we want to set and the value we want to set there.

Let's look at an example to work with. This is a fairly basic reducer:

export type Post = {
  title: string,
  body: string
}

export interface PostState {
  post: Post
}

export const initialPostState: PostState = {
  post: {
    title: "Redux State is Cool",
    body: "We can change global state in Redux"
  }
}

export default (state: PostState = initialPostState, action: PostAction): PostState => {
  switch (action.type) {
    case actions.POST_SET_TITLE:
      const { title } = action.payload
      return { ...state, post: { ...state.post, title } }
    case actions.POST_SET_BODY:
      const { body } = action.payload
      return { ...state, post: { ...state.post, body } }
    default:
      return state
  }
}

And a fairly standard way to plug it into our Redux store:

export type RootState = {
  posts: PostState
}

export const initialRootState: RootState = {
  posts: initialPostState
}

const reducers = combineReducers<RootState>({
  posts
})

Now we have reducers, which is a combined reducer that can handle all of our actions as. Of course, there would be other sub-reducers here besides just posts, but this is just for the sake of example.

Now lets add a new action and a special action handler on our root-level reducer:

// action
export enum GlobalActionType {
    GLOBAL_SET_STATE = "global/SET_STATE"
}

export const setGlobal = (path: string | string[], value: any) => ({
    type: GlobalActionType.GLOBAL_SET_STATE,
    payload: {
        path,
        value
    }
})

...

// reducer
import dotProp from 'dot-prop-immutable'

const sliceReducers = combineReducers<RootState>({
  posts
})

export default (state: RootState = initialRootState, action: AnyAction): RootState => {
  switch (action.type) {
    case GlobalActionType.GLOBAL_SET_STATE:
      const { path, value } = action.payload
      return dotProp.set(state, path, value)
    default:
      return sliceReducers(state, action)
  }
}

Now we have a new action that contains the path in the state tree to the value we want to set and the value we want to set it to, just like we mentioned before.

Then we created a new reducer function that will act as our root reducer. It handles our one new action, otherwise it defers to the normal reducer created with combineReducers. This has to be done at the root-level reducer because we want it to have access to the full state tree. We can't just add it as another reducer we pass into combineReducers because then it would just have access to its own separate section of our global state tree.

In our action handler, we use dot-prop-immutable to handle the immutable state update, which is a great library to handle immutable updates of a deep state tree using a path string. It's as simple as it sounds. If we want to update title in { posts: { post: { title, body } } }, we just do dotProp.set("posts.post.title", "Our new value"), and it handles updating the root object immutably.

Let's look at how we can use this in a component.

<button onClick={() => this.props.setGlobal("posts.post.title", "Our new value")}>
    Update Title
</button>

Just feed in the setGlobal action creator to your component just like you'd do with any action creator, i.e. through connect and mapDispatchToProps.

And, well, that's it! We have an easy way to update any piece of your redux state tree without creating a new action type or creator for each one. At this point, we can actually get rid of our traditional action types, action creators, and reducers for our posts, since we can just set them both with setGlobal.

Now, if referencing parts of your state tree with strings hurts you as much as it does me, then let's try to do better. I am using Typescript here after all.

Type Safety

We need to make a way to get the path to any value in our state tree in a type-safe manner. What's required to do this is an object that's a complete representation of our entire state tree. Luckily, we have initialRooteState, which has initial values for every piece of data in the root state tree. Ensure that every possible piece of state is defined in the object where it should be, even if it doesn't have some initial value because we need all the key names of every piece of state.

We want to create a representation of our state object with all the same keys and where each key has a property that contains a string path to it. We can use Typescript's mapped types and some object traversal code for this.

interface StatePath {
  $p: string
}

type PathTransform<T> = {
  [K in keyof T]: PathTransform<T[K]> & StatePath
}

function generatePaths<T>(obj: T, prefix = ""): PathTransform<T> {
  const keys: string[] = Object.keys(obj);
  const pathPrefix = prefix ? `${prefix}.` : "";

  return keys.reduce((result, key) => {
    const path = `${pathPrefix}${key}`
    const val: any = (obj as any)[key]
    if (isObject(val)) {
      (result as any)[key] = {
        $p: path,
        ...generatePaths(val, path)
      }
  } else (result as any)[key] = { $p: path }
    return result;
  }, {} as PathTransform<T>);
}

I'm using lodash's isObject function here, but you could just as easily implement this function yourself. generatePath recursively traverses the object and adds a property $p with the path to that key. I won't get into the technicals of how these types and function works--I'll leave that as an exercise for the reader.

For example, this:

{
  posts: {
    post: {
      title: ""
    }
  }
}

turns into this:

{
  posts: {
    $p: "posts",
    post: {
      $p: "posts.post",
      title: {
        $p: "posts.post.title"
      }
    }
  }
}

I'm only using $p to cut down on the length of these references as much as possible. This saves 2 characters (two whole characters!!) over the word path

So if we want the path to title, we can get it from our object of paths: generatedPaths.posts.post.title.$p. Now we get auto-completion and Typescript will let us know if we're referencing a key that doesn't exist.

The great thing about this approach is that it's generated dynamically, so as long as we keep our initial state object in accordance with our actual state tree, we have type safety baked right in. I had originally considered an approach that would requiring running a script to generate this object of paths that would require a re-run every time the state tree changed at all. That would certainly be a huge pain.

Let's look at an example of using this in a component:

import $p from '../redux/statePaths'

<button onClick={() => this.props.setGlobal($p.posts.post.body.$p, "Our new value")}>
    Update Title
</button>

And just some proof that the auto-completion works for the haters 😎

Examples

I've put together a small React project illustrating everything I mentioned in this post. Hopefully it will put together any pieces together that I left out here so you can get a complete picture of what this implementation would look like.

Looking Forward

To be clear, there's nothing stopping us from simply using nested objects to define our state updates, just like we would with this.setState in a React component. The reason I chose to go with dotProp is that state trees tend to have more than a few levels of depth, so string paths with dot-prop-immutable seemed like the least verbose way to do it.

There are some great improvements that could be made here with React hooks. It could cut down the boilerplate even further by not having to pass in setGlobal to mapDispatchToProps. Even futher, you could use hooks to cut down on boilerplate to read from Redux state too. I'll have to do some more digging into React hooks and see what I can come up with, but it looks like a promising future for boilerplate-less React & Redux.

Last updated