Put a Soul into a React-Redux Project (using Actors for Business Logic)
simprl
Posted on September 1, 2022
Imaging we have ReactJS for UI and Redux for store data. Users see a button, click on it, redux action happens, data stored in the global redux state and button change visual style. This looks like just a cause and an effect. It feels lifeless. Such applications are similar to primitive species.
Another thing - a button that after clicking waits for a second, simulating the thought process, and then changes the UI. And then waits one more second and turns itself off.
In this article I will try to explain how to add a brain into an application and making it smarter. You can call this as “adding a Soul to a React+Redux project” or "using Actors for business logic". I call it “Ghost in the React”.
User interact with React components. React components call actions. Reducer change state in the Redux.
Now the Ghost comes into play. Ghost sees that the state has changed and does some work and calls actions to save the result in the Redux. React components see that state changed and show an updated UI to the user.
Step 1. Install react with typescript
npx create-react-app rg_example --template typescript
You can find a lot of tutorials about. So go to next step.
Step 2. Prepear page.
Let's clean up file App.tsx.
import React, {createContext, useContext, useMemo, useState} from 'react';
import './App.css';
type ContextInterface = {
state: boolean;
setState: (state: boolean) => void;
};
const Context = createContext<ContextInterface>({
state: false,
setState: (state: boolean) => undefined,
});
const App = () => {
const [state, setState] = useState(false);
const contextValue = useMemo(() => ({state, setState}), [state]);
return <Context.Provider value={contextValue}>
<Panel />
</Context.Provider>;
};
const Panel = () => {
const {state, setState} = useContext(Context);
return (
<div className='App'>
<button onClick={() => {
setState(!state);
}} >{state ? 'disable' : 'enable'}</button>
<span>{state ? 'enabled' : 'disabled'}</span>
</div>
);
};
export default App;
We added the button. When click - state change and button just change a text.
Step 3. Adding a Ghost.
const ButtonGhost = () => {
const {state, setState} = useContext(Context);
useEffect(() => {
if (state) {
const id = setTimeout(() => {
setState(false);
}, 1000);
return () => {
clearTimeout(id);
};
}
}, [state, setState]);
return null;
};
const App = () => {
const [state, setState] = useState(false);
const contextValue = useMemo(() => ({state, setState}), [state]);
return <Context.Provider value={contextValue}>
<Panel />
<ButtonGhost />
</Context.Provider>;
};
That's it. If you run this code you've already seen alive panel.
You click "enable" and it changes the state to "enabled". After 1 second Ghost changes state back to "disabled".
Step 3. Time to add redux.
Before we store state using the React Context and hook useState.
Let's move the panel state to the Redux.
At first install packages:
npm i redux @reduxjs/toolkit use-store-path
I don't like the react-redux
package because I don't need all the functionality of this package. It's enough for me just subscribe to state change. So I use use-store-path
instead.
import React, {createContext, useContext, useEffect} from 'react';
import {createSlice, configureStore} from '@reduxjs/toolkit';
import type {PayloadAction} from '@reduxjs/toolkit';
import {getUseStorePath} from 'use-store-path';
import './App.css';
export const stateSlice = createSlice({
name: 'flag',
initialState: false,
reducers: {
set: (state, action: PayloadAction<boolean>) => action.payload,
},
});
const store = configureStore({reducer: stateSlice.reducer});
const exStore = {
...store,
useStorePath: getUseStorePath(store),
};
const Context = createContext(exStore);
const App = () => <Context.Provider value={exStore}>
<Panel />
<ButtonGhost />
</Context.Provider>;
const Panel = () => {
const {useStorePath, dispatch} = useContext(Context);
const flag = useStorePath([]);
return (
<div className='App'>
<button onClick={() => {
dispatch(stateSlice.actions.set(!flag));
}} >{flag ? 'disable' : 'enable'}</button>
<span>{flag ? 'enabled' : 'disabled'}</span>
</div>
);
};
const ButtonGhost = () => {
const {useStorePath, dispatch} = useContext(Context);
const flag = useStorePath([]);
useEffect(() => {
if (flag) {
const id = setTimeout(() => {
dispatch(stateSlice.actions.set(false));
}, 1000);
return () => {
clearTimeout(id);
};
}
}, [flag, dispatch]);
return null;
};
export default App;
Please note that in this article we only store one flag to simplify the examples. In a real project, each reducer will have more values and actions.
Step 4. Dynamic reducer.
Dynamic reducer is very useful in a large application.
I use package @simprl/dynamic-reducer
for it.
npm i @simprl/dynamic-reducer
Add useReducer hook to the application Context
import {reducer as dynamicReducer} from '@simprl/dynamic-reducer';
import {Reducer} from 'redux';
const {reducer, addReducer} = dynamicReducer();
// before: const store = configureStore({reducer: stateSlice.reducer});
const store = configureStore({reducer});
const useReducer = (name: string, reducer: Reducer) => {
useEffect(
() => addReducer(name, reducer, store.dispatch),
[name, reducer],
);
};
const exStore = {
...store,
useStorePath: getUseStorePath(store),
useReducer,
};
Now we can dynamically create reducer in the Ghost using useReducer
. We just need every time define a space ("flag1") when dispatch action and subscribe to the Redux store.
const ButtonGhost = () => {
useReducer('flag1', stateSlice.reducer);
const {useStorePath, dispatch} = useContext(Context);
const flag = useStorePath(['flag1']);
useEffect(() => {
if (flag) {
const id = setTimeout(() => {
dispatch({...stateSlice.actions.set(false), space: 'flag1'});
}, 1000);
return () => {
clearTimeout(id);
};
}
}, [flag, dispatch]);
return null;
};
Step 5. Many spaces.
Now we can reuse same reducer in two spaces ("flag1" and "flag2")
const App = () => <Context.Provider value={exStore}>
<Panel space='flag1' />
<Panel space='flag2' />
<ButtonGhost space='flag1' />
<ButtonGhost space='flag2' />
</Context.Provider>;
type WithSpace = {
space: string;
};
const Panel = ({space}: WithSpace) => {
const {useStorePath, dispatch} = useContext(Context);
const flag = useStorePath([space]);
return (
<div className='App'>
<button onClick={() => {
dispatch({...stateSlice.actions.set(!flag), space});
}} >{flag ? 'disable' : 'enable'}</button>
<span>{flag ? 'enabled' : 'disabled'}</span>
</div>
);
};
const ButtonGhost = ({space}: WithSpace) => {
useReducer(space, stateSlice.reducer);
const {useStorePath, dispatch} = useContext(Context);
const flag = useStorePath([space]);
useEffect(() => {
if (flag) {
const id = setTimeout(() => {
dispatch({...stateSlice.actions.set(false), space});
}, 1000);
return () => {
clearTimeout(id);
};
}
}, [flag, dispatch]);
return null;
};
export default App;
Button Component and Ghost can work with different spaces. So we can reuse button, ghost and reducer with the same functionality but for different space in the Redux.
Step 6. Click handler.
But that is not all. You might have noticed that we always recreate pointer to the click function. It leads to a rerender memoized component because we put a new property each time.
Usually it fix by useCallback
const Panel = ({space}: WithSpace) => {
const {useStorePath, dispatch} = useContext(Context);
const flag = useStorePath([space]);
const clickHandler = useCallback(() => {
dispatch({...stateSlice.actions.set(!flag), space});
}, [space, flag]);
return (
<div className='App'>
<button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
<span>{flag ? 'enabled' : 'disabled'}</span>
</div>
);
};
But you can see property 'flag' and 'space' in the deps. So we still have similar problem. On each click this 'flag' property changes and we again recreate function. Actually pointer to the handler should not depends from 'flag' and 'space' properties. But when handler call we need to get values of these properties from the last render.
I use hook useConstHandler from package use-constant-handler
npm i use-constant-handler
import {useConstHandler} from 'use-constant-handler';
const clickHandler = useConstHandler(() => {
dispatch({...stateSlice.actions.set(!flag), space});
});
useConstHandler every time returns a constant pointer to the function, but the context of this function changes every render - it means all properties in this function are always actual.
Step 7. useAction hook.
Usually in my projects I create hook for call action.
const useSpaceAction = (
space: string,
actionCreator: () => AnyAction
) => useConstHandler(() => {
store.dispatch({space, ...actionCreator()});
});
const exStore = {
...store,
useStorePath: getUseStorePath(store),
useReducer,
useSpaceAction,
};
Now Panel component look like this
const Panel = ({space}: WithSpace) => {
const {useStorePath, useSpaceAction} = useContext(Context);
const flag = useStorePath([space]);
const clickHandler = useSpaceAction(space, () => stateSlice.actions.set(!flag));
return (
<div className='App'>
<button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
<span>{flag ? 'enabled' : 'disabled'}</span>
</div>
);
};
Step 8. Separation into UI and business logic.
Good practice is separate UI and business logic. Let's do it.
const App = () => <Context.Provider value={exStore}>
<div className='App'>
<AppUi />
</div>
<AppGhost/>
</Context.Provider>;
Now we have two laysers of the application:
- AppUi for UI
- AppGhost for business logic
Let's implement them and add more interactivity
const AppGhost = () => {
const {useStorePath} = useContext(Context);
const flag1 = useStorePath(['flag1']);
return <>
<ButtonGhost space='flag1' />
{flag1 && <ButtonGhost space='flag2' />}
</>;
};
const AppUi = () => {
const {useStorePath} = useContext(Context);
const flag1 = useStorePath(['flag1']);
return <>
<Panel space='flag1' />
{flag1 && <Panel space='flag2' />}
</>;
};
Second panel and ghost render only if flag1 === true.
Note that this is also an example of the advantage of a dynamic reducer. Reducer for flag2 will add only if flag1 is true and will remove when flag1 === false
Step 9. Don't use JSX for business logic.
Using JSX look strange for business logic. We can get confused if we use JSX in both cases. We need to somehow distinguish the business logic code from the UI code. I suggest to use the keywords 'ghost' and 'ghosts'.
import { createElement, Fragment } from 'react';
const ghost = createElement;
const ghosts = (...children) => createElement(Fragment, null, ...children);
To standardize this I use the same package react-ghost
in all my projects.
npm i react-ghost
Lets rewrite AppGhost without jsx
import {ghost, ghosts} from 'react-ghost';
const AppGhost = () => {
const {useStorePath} = useContext(Context);
const flag1 = useStorePath<boolean>(['flag1']);
return ghosts(
ghost(ButtonGhost, {space: 'flag1'}),
flag1 && ghost(ButtonGhost, {space: 'flag2'}),
);
};
Result
Entire file:
import React, {createContext, useContext, useEffect} from 'react';
import {createSlice, configureStore, AnyAction} from '@reduxjs/toolkit';
import type {PayloadAction} from '@reduxjs/toolkit';
import {reducer as dynamicReducer} from '@simprl/dynamic-reducer';
import {getUseStorePath} from 'use-store-path';
import './App.css';
import {Reducer} from 'redux';
import {useConstHandler} from 'use-constant-handler';
import {ghost, ghosts} from 'react-ghost';
export const stateSlice = createSlice({
name: 'flag',
initialState: false,
reducers: {
set: (state, action: PayloadAction<boolean>) => action.payload,
},
});
const {reducer, addReducer} = dynamicReducer();
const store = configureStore({reducer});
const useReducer = (name: string, reducer: Reducer) => {
useEffect(
() => addReducer(name, reducer, store.dispatch),
[name, reducer],
);
};
const useSpaceAction = (space: string, actionCreator: () => AnyAction) => useConstHandler(() => {
store.dispatch({space, ...actionCreator()});
});
const exStore = {
...store,
useStorePath: getUseStorePath(store),
useReducer,
useSpaceAction,
};
const Context = createContext(exStore);
const App = () => <Context.Provider value={exStore}>
<div className='App'>
<AppUi />
</div>
<AppGhost/>
</Context.Provider>;
type WithSpace = {
space: string;
};
const Panel = ({space}: WithSpace) => {
const {useStorePath, useSpaceAction} = useContext(Context);
const flag = useStorePath([space]);
const clickHandler = useSpaceAction(space, () => stateSlice.actions.set(!flag));
return (
<div>
<button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
<span>{flag ? 'enabled' : 'disabled'}</span>
</div>
);
};
const ButtonGhost = ({space}: WithSpace) => {
useReducer(space, stateSlice.reducer);
const {useStorePath, dispatch} = useContext(Context);
const flag = useStorePath([space]);
useEffect(() => {
if (!flag) {
const id = setTimeout(() => {
dispatch({...stateSlice.actions.set(true), space});
}, 1000);
return () => {
clearTimeout(id);
};
}
}, [flag, dispatch]);
return null;
};
const AppUi = () => {
const {useStorePath} = useContext(Context);
const flag1 = useStorePath(['flag1']);
return <>
<Panel space='flag1' />
{flag1 && <Panel space='flag2' />}
</>;
};
const AppGhost = () => {
const {useStorePath} = useContext(Context);
const flag1 = useStorePath<string>(['flag1']);
return ghosts(
ghost(ButtonGhost, {space: 'flag1'}),
flag1 && ghost(ButtonGhost, {space: 'flag2'}),
);
};
export default App;
You can play with this in codesandbox:
Source code
Source code is available on github
You can check each step in the commit history
FAQ
Why don't you just use redux middleware (your own or Thunk or Saga)?
Middleware doesn't have hooks.
Why don't you just use hooks instead of ghost?
Hooks can’t have conditions. You cannot make a dynamic composition of hooks. So while it is not a problem - you can use hooks.
Posted on September 1, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.