Skip to content
This repository has been archived by the owner on Dec 4, 2023. It is now read-only.

Local container state for Redux based on the Elm Architecture

License

Notifications You must be signed in to change notification settings

HansDP/redux-container-state

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

This project is an attempt to integrate local container state into a Redux store, which is global by nature.

Influences

This project evolves the ideas redux-elm, but avoids opinions about specific implementations of Side Effects and tries to be more in line with the Redux approach of reducers.

Because this project is influenced by redux-elm, which is in its term highly influenced by The Elm Architecture, most concepts will be familiar to both projects.

What is this project trying to solve?

This project tries to solve the same problem as the redux-elm project. Basically, it comes down to this:

In Redux, state is considered global. That makes it hard to create isolated and reusable container components, which require their own local state. This projects tries to abstract away the complexity to handle this problem.

The Gist

redux-container-state is about reusable Containers (which are basically React components that control pieces of an application). These Containers typically require some local state. To solve this within redux, you have to find solutions to link actions and components to dedicated locations within the state graph.

With redux-container-state, it should become a lot more easy to solve this, because your reusable containers do not have to care about the above problem anymore.

To create containers that benefit from this approach, your container should at least consist of 2 pieces:

  • View: This is your typical React implementation, wrapped within a higher order component that handles isolation.
  • Updater: This is your typical Redux reducer, wrapped within a higher order function that handles isolation.

In the Elm architecture, the typical example is about a counter. So, lets get started with the same example.

Counter

Counter updater
import { updater } from 'redux-container-state'

const initialModel = 0

// The updater(...) function handles isolation of the Counter reducer.
export default updater((model = initialModel, action) => {
  switch (action.type) {

    case 'Increment':
      return model + 1

    case 'Decrement':
      return model - 1
      
    default:
      return model
  }
})
Counter view
import React from 'react'
import { view } from 'redux-container-state'

export default view(({ model, localDispatch }) => (
  <div>
    <button onClick={() => localDispatch({ type: 'Decrement' })}>-</button>
    <div>{model}</div>
    <button onClick={() => localDispatch({ type: 'Increment' })}>+</button>
  </div>
))

Composition

The Counter sample is the most simple example, but it should give you an idea of the way of working.

Pair of counters

To up the ante, let us create a parent container that holds two Counters (the pair-of-counters use case): a topCounter and a bottomCounter.

You can re-use the Counter updater and view from the example above. After all, this is the whole idea behind this project: being able to reuse containers.

Little side note: you actually need to change one detail in the Counter example above: the parent view should know the initial state of its child containers. This requires you to just export the default initalModel of the Counter updater:

export default const initialModel = 0
Parent view
import React from 'react'
import { forwardTo, view } from 'redux-container-state'

import Counter from '../counter/view'

export default view(({ model, localDispatch }) => (
  <div>
    <Counter model={model.topCounter} localDispatch={forwardTo(localDispatch, 'TopCounter')} />
    <Counter model={model.bottomCounter} localDispatch={forwardTo(localDispatch, 'BottomCounter')} />
    <button onClick={() => localDispatch({ type: 'Reset' })}>RESET</button>
  </div>
))

The above sample actually explains the internal working of the composition mechanism: the parent container prepares a new localDispatch method for its child containers. This new localDispatch method is capable of composing an hierarchical action.

For instance: forwardTo(localDispatch, 'TopCounter') creates a new localDispatch method that will wrap dispatches of the child container into the TopCounter context. This context can then be used within the parent updater to inspect the targetted child container.

Parent updater
import { updater } from 'redux-container-state'
import counterUpdater, { initialModel as counterInitialModel } from '../counter/updater'

const initialModel = {
  topCounter: counterInitialModel,
  bottomCounter: counterInitialModel
}

export default updater((model = initialModel, action) => {
  switch (action.type) {

    case 'Reset':
      return initialModel

    case 'TopCounter': 
      return {
        ...model,
        topCounter: counterUpdater(model.topCounter, action)
      }

    case 'BottomCounter': 
      return {
        ...model,
        bottomCounter: counterUpdater(model.bottomCounter, action)
      }
      
    default:
      return model
  }
})

The parent updator is aware of its child updaters. The library takes care of unwrapping the parent's action into a child action, so you just pass the action to the child updater.

Dynamic composition

In a lot of cases, a hard-coded set of child containers is sufficient. However, there is a huge use-case for a dynamic set of child containers.

Dynamic list of counters

Parent view
import React from 'react'
import { forwardTo, view } from 'redux-container-state'

import Counter from '../counter/view'

const viewCounter = (localDispatch, model, index) =>
  <Counter key={index} localDispatch={ forwardTo(localDispatch, 'Counter', index) } model={ model } />

export default view(({ model, localDispatch }) => (
  <div>
    <button onClick={ () => localDispatch({ type: 'Remove' }) }>Remove</button>
    <button onClick={ () => localDispatch({ type: 'Insert' }) }>Add</button>
    {model.map((counterModel, index) => viewCounter(localDispatch, counterModel, index))}
  </div>
))

Because there are an unknown amount of child containers, the parent view is not capable of forwarding the localDispatch method in a predictable way (without trickery code, that is).

That is why the forwardTo method can take an additional parameter, which parameterizes the action type that is being forwarded. This parameter can then be used within the parent's updater.

Note: this parameter can be of any type, but it should be serializable to a string (e.g. integers, strings, floats, ...).

Parent updater
import { updater } from 'redux-container-state'
import counterUpdater, { initialModel as counterInitialModel } from '../counter/updater'

export default updater((model = [], action) => {

    switch (action.type) {

        case 'Insert': 
            return [
                ...model,
                counterInitialModel
            ]

        case 'Remove':
            if (model.length > 0) {
                const counters = [ ...model ]
                counters.pop()
                return counters
            }
            return model

        case 'Counter':
            return model.map((counterModel, index) => {
                if (index === action.typeParam) {
                    return counterUpdater(counterModel, action)
                }
                return counterModel
            })

        default:
            return model
    }
})

The updater can use the typeParam to check the targetted child container (as set in the forwardTo method). Because parameterization is mainly used within the context of arrays (and thus the index in the array will be the parameter), the framework deserializes numbers back to valid JavaScript types (which takes away the burder to have to parse the parameter to a number yourself.)

View enhancers

Local middleware

Note: This is highly experimental and has not validated against multiple use-case. However, local thunk middleware is up and running. Yay!

In order for your reusable container to be truly isolated, you probably need some middleware that only applies to your container only.

import React from 'react'
import { compose } from 'redux'
import { view, applyLocalMiddleware } from 'redux-container-state'
import localThunk from 'redux-container-state-thunk'


const increment = () => {
  return {
    type: 'INCREMENT_COUNTER'
  }
}

const incrementAsync = () => {
  return (localDispatch, getState) => {
    setTimeout(() => {
      localDispatch(increment());
    }, 1000)
  }
}

const counterUpdater = updater((model = 0, action) => {
  switch (action.type) {
    case 'INCREMENT_COUNTER': 
      return model + 1
    default:
      return model
  }
})

const viewWithMiddleware = compose(applyLocalMiddleware(localThunk))(view)

// Pass the middlewares you need to the view method.
export default viewWithMiddleware(({model, localDispatch}) => (
  <div>
    <button onClick={ () => localDispatch(incrementAsync()) }>Start counter</button>
    Current count: { model }
  </div>
))

Global state

In some cases, you will want to get access to the global state of Redux within your view(). For that use-case, take a look at the global-state enhancer at redux-container-state-globalstate.

Side Effects with redux-saga

If you wish to incoporate redux-saga into your local containers, you can have redux-container-state-globalsaga. This extension enables Sagas that have access to actions and state from both global (redux store) and local (container) sources.

If you need Sagas that work on containers only (so only local actions and state), there is another option as well: redux-container-state-saga

Some remarks

Actions are dispatched globally

Worth noting is that all actions that originate from a view() are dispatched globally. This might not be obvious at first sight, but if you consider that Redux is the backing state store for this framework, it makes a lot more sense.

This also means that you can register any middleware and/or store enhancer in Redux to handle some concerns from your child containers (but be careful, in some cases this is exactly what you don't want).

When composed actions are send to Redux, they will follow a predictable format. Suppose you have a container that holds a dynamic list of counters, the type of the action in global scope will look like Counter[4]->Increment. Or deeply nested, it could look like Child[1]->GrandChild[2]->TopCounter->Increment.

Inspecting the global type in an updater

In some cases, you will want to inspect the globally dispatched action. To get a hold of this within an updater(reducer), you can inspect the action.globalType property.

Examples

Installation & Usage

You can install redux-container-state via npm.

npm install redux-container-state --save

About

Local container state for Redux based on the Elm Architecture

Resources

License

Stars

Watchers

Forks

Packages

No packages published