DEV Community

Cover image for Put a Soul into a React-Redux Project (using Actors for Business Logic)
simprl
simprl

Posted on • Updated on

Put a Soul into a React-Redux Project (using Actors for Business Logic)

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”.

React-Redux-Ghost

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;
Enter fullscreen mode Exit fullscreen mode

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>;
};

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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});
});
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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>
    );
};

Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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' />}
    </>;
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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'}),
    );
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)