Skip to content

Instantly share code, notes, and snippets.

@frankiesardo
Last active July 28, 2020 21:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save frankiesardo/64466080d195991d080974860b02fc63 to your computer and use it in GitHub Desktop.
Save frankiesardo/64466080d195991d080974860b02fc63 to your computer and use it in GitHub Desktop.
Colocating effects and reducers

The big idea

A user action triggers a state transition and sometimes a side effect.

I want to be able to have a reducer that return both: a representation of how the state should change and which side effects are triggered.

There are existing solutions to colocate reducer and side effects:

None of them gives what I think is needed to reason about a program: a data representation of what should happen.

The reducer used in this example still takes data in and data out, nothing more. It still changes the app state. But it also adds a few extra keys that represent data needed for side effects.

The logic sits in useReducerWithEffects: when it sees keys that are not state it executes the side effects (given the handler map passed as a parameter) and cleans them up once they are done.

In a nutshell

Define how each side effect is executed (your handlers)

const handlers = { log: (dispatch, payload) => console.log(payload), 
                   http: (dispatch, payload) => "Do an http call and then call dispatch with the result" }

So that you can write a reducer like this

function reducer(state, action) {
  switch (action.type) {
    case actionType.LOGIN:
      return {
        state: { ...state, isLoading: true },
        log: action,
        http: { url: "login-url", params: {}, callback: actionType.LOGIN_CALLBACK }
      }

Add a hook call at the top of your application

let [state, dispatch] = useReducerWithEffects(reducer, handlers, initialState)

And the rest of your components don't need to care about side effects

Credit

Inspiration for the shape of the returned data is taken from the great citrus library (now deprecated): https://github.com/clj-commons/citrus#usage

// This is an example on how to use the library
import React from 'react';
// This is the hook
function useReducerWithEffects(reducer, handlers, initialState) {
const [{ state, ...effects }, setState] = React.useState({ state: initialState })
const dispatch = React.useCallback(action => setState(({ state }) => ({ state, ...reducer(state, action) })), [reducer])
React.useEffect(function () {
if (Object.keys(effects).length === 0) return
for (let [handlerKey, effect] of Object.entries(effects)) {
let handler = handlers[handlerKey]
if (!handler) throw new Error(`Unrecognised handler key ${handlerKey}`)
handler(dispatch, effect)
}
setState(({ state }) => { return { state } });
}, [handlers, dispatch, effects])
return [state, dispatch]
}
// This is how to use it
const actionType = {
LOGIN: "login",
LOGIN_CALLBACK: "logingCallback",
LOGOUT: "logout",
LOGOUT_CALLBACK: "logoutCallback"
}
function reducer(state, action) {
switch (action.type) {
case actionType.LOGIN:
return {
state: { ...state, isLoading: true },
log: action,
http: { url: "login-url", params: {}, callback: actionType.LOGIN_CALLBACK }
}
case actionType.LOGIN_CALLBACK:
return {
state: { ...state, isLoading: false, profile: action.payload.body.profile },
log: action
}
case actionType.LOGOUT:
return {
state: { ...state, isLoading: true, profile: null },
log: action,
http: { url: "logout-url", params: {}, callback: actionType.LOGOUT_CALLBACK }
}
case actionType.LOGOUT_CALLBACK:
return {
state: { ...state, isLoading: false },
log: action
}
default:
throw action
}
}
function http(dispatch, { callback, url, params }) {
setTimeout(function () {
console.log(`Calling server on ${url}`);
dispatch(
{
type: callback,
payload: {
success: true,
status: 200,
body: { profile: { name: "Alice" } }
}
})
}, 1000)
}
function log(dispatch, payload) {
console.log("Logging..", payload)
}
const handlers = { log: log, http: http }
const initialState = { isLoading: false }
const DispatchContext = React.createContext();
const Home = React.memo(({ state }) => {
const dispatch = React.useContext(DispatchContext)
const { isLoading, profile } = state
const onLogin = () => { dispatch({ type: actionType.LOGIN }) }
const onLogout = () => { dispatch({ type: actionType.LOGOUT }) }
return (
<div>
<header>
<button onClick={onLogin}>
Login
</button>
<button onClick={onLogout}>
Logout
</button>
{isLoading ? (<p> Loading... </p>)
: profile ? (<p> Welcome, {profile.name} </p>)
: (<p> Logged out </p>)}
</header>
</div>
);
})
function App() {
const [state, dispatch] = useReducerWithEffects(reducer, handlers, initialState)
return (
<DispatchContext.Provider value={dispatch}>
<Home state={state} />
</DispatchContext.Provider>
)
}
export default App;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment