block by whiteinge 1b796d1ae7e1d0eb1457897a95db4a82

Redux and redux-thunk implemented as component-state, or hook-state, and/or context-state

Full Screen

index.html

<!doctype html>
<html lang=en>
<head>
    <meta charset=utf-8>
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <style>
    .spinner {
        margin: 0;
        display: inline-block;
        font-size: 1em;

        animation-name: spin;
        animation-duration: 1000ms;
        animation-iteration-count: infinite;
        animation-timing-function: linear;
    }

    @keyframes spin {
        from {transform:rotate(360deg);}
        to {transform:rotate(0deg);}
    }
    </style>
</head>

<body>
    <script src="https://unpkg.com/lodash@4.17.21/lodash.js"></script>
    <script src="./reducer.js"></script>
    <script>
    // Logic & Components
    const A = objMirror([
        'INITIALIZE',
        'INC', 'DEC',
        'INC_START', 'INC_LOADING', 'INC_LOADED', 'INC_ERROR',
        'DEC_START', 'DEC_LOADING', 'DEC_LOADED', 'DEC_ERROR',
    ])

    const icons = { cyclone: String.fromCodePoint(0x1F300) };
    const getInitialState = () => ({inited: false, count: 0, loading: false, data: null, error: null})

    const reducers = {
        [A.INITIALIZE]: (state, action) => ({...state, inited: true}),

        [A.INC]: (state, action) => ({...state, count: state.count + action.payload}),
        [A.DEC]: (state, action) => ({...state, count: state.count - action.payload}),

        [A.INC_LOADING]: (state, action) => ({...state, loading: true}),
        [A.INC_LOADED]: (state, action) => ({ ...state, loading: false, count: state.count + action.payload, error: null }),
        [A.INC_ERROR]: (state, action) => ({ ...state, loading: false, data: null, error: action.payload }),
    }

    const effects = {
        [A.INC_START]: (send, action, {wait}) =>
            send(A.INC_LOADING, null)
                .then(() => wait(1000)) // ajax or whatever
                .then(() => send(A.INC_LOADED, action.payload))
                .catch((err) => send(A.INC_ERROR, err))
    }

    const Spinner = () => `<span class="spinner">${icons.cyclone}</span>`;

    const MyComponent = (props) => {
        return `
            <div>
                Count: ${props.count} ${props.loading ? Spinner() : ''}
                <br>
                Sync:
                <button type="button" onclick="send(A.INC, 1)">+</button>
                <button type="button" onclick="send(A.DEC, 1)">-</button>
                <br>
                Async:
                <button type="button" onclick="send(A.INC_START, 1)" ${props.loading ? 'disabled' : ''}>+</button>
                <button type="button" onclick="send(A.INC_START, -1)" ${props.loading ? 'disabled' : ''}>-</button>
            </div>
        `
    }

    // Stub an encapsulated state object & render cycle for a React-less demo.
    window.send = makeSend.call({
        state: getInitialState(),
        setState: function (objOrFn, cbFn) {
            this.state = objOrFn(this.state);
            render(MyComponent(this.state));
            cbFn(this.state);
        },
    }, reducers, effects);

    // Usually set by Node or Webpack.
    // Show action dispatches in the browser console.
    window.DEBUG = true;

    // Run the app!
    ready(() => { send(A.INITIALIZE, null); })

    // ---

    function render(content) {
        window.document.body.innerHTML = content;
    }

    function ready(fn) {
        if (document.readyState != 'loading') {
            fn();
        } else {
            document.addEventListener('DOMContentLoaded', fn);
        }
    }
    </script>
</body>
</html>

reducer.js

/**
Any ajax or other asynchronous functions that should be dependency-injected
into effect functions to allow easy replacement/mocking for unit tests.
**/
const requestDeps = {
    fetch: window.fetch,

    // setTimeout as a Promise
    wait: (time) => new Promise((res) => setTimeout(res, time)),
};

/**
Create a single reducer function from an object of reducer functions each
filtered by an action constant

Usage:

    const A = objMirror(['FOO', 'BAR'])
    const reducers = {}
    reducers[A.FOO] = (state, action) => ({
        ...state, foo: action.payload,
    })
    reducers[A.BAR] = (state, action) => ({
        ...state, bar: action.payload,
    })
    const reducer = makeReducer(reducers)
**/
const makeReducer = (reducers) => (state, action) => {
    const fn = reducers[action.type];
    if (fn) {
        return fn(state, action);
    }
    return state;
};

/**
Wrap setState to use as a Flux-like dispatcher

NOTE: `send` is curried and _must_ be called with _two_ arguments!

`send` returns a Promise that isn't resolved until the associated reducer has
completed, state has been set, and the page rendered. This allows you to choose
sequential execution by dot-chaining or parallel execution by not dot-chaining
(or using `Promise.all` to make that explicit).

If an event is dispatched as the second argument to `send` this will attempt to
extract form data or name/value pairs from the event as the action payload. The
original event is available as `action.event`.

Usage:

    const A = objMirror(['INC', 'DEC',
        'START', 'LOADING', 'LOADED_SUCCESS', 'LOADED_ERROR'])

    const getInitialState = () => ({count: 0, loading: false, data: null, error: null})

    const reducers = {
        [A.INC]: (state, action) => ({...state, count: state.count + action.payload}),
        [A.DEC]: (state, action) => ({...state, count: state.count - action.payload}),

        [A.LOADING]: (state, action) => ({...state, loading: true}),
        [A.LOADED_SUCCESS]: (state, action) => ({
            ...state,
            loading: false,
            data: action.payload,
            error: null,
        }),
        [A.LOADED_ERROR]: (state, action) => ({
            ...state,
            loading: false,
            data: null,
            error: action.payload,
        }),
    }

    const effects = {
        [A.START]: (send, action, {request, checkOk}) =>
            send(A.LOADING, null)
                .then(() => request('/some/path'))
                .then(checkOk())
                .then((rep) => send(A.LOADED_SUCCESS, rep.data))
                .catch((err) => send(A.LOADED_ERROR, err))
    }

    class MyComponent extends React.Component {
        state = getInitialState();
        send = makeSend.call(this, reducers, effects);

        componentDidMount() {
            this.send(A.START, null)
        }

        render() {
            return (
                <div>
                    Count: {this.state.count} {this.state.loading && (
                        <Spinner />
                    )}
                    <br/>
                    <button type="button" onClick={() => this.send(A.INC, 1)}>Increment</button>
                    <br/>
                    <button type="button" onClick={() => this.send(A.DEC, -1)}>Decrement</button>
                </div>
            )
        }
    }

Also usable as global state via Context:

    // Define a new context somewhere import-able:
    export const AppContext = React.createContext({});

    // ...

    // Define actions, reducers, effects, and state in a parent component.
    // Then expose those to child components as a context provider (class example):
    import {AppContext} from './some/place';

    const A = objMirror(['FOO']);
    const reducers = {};
    const effects = {};
    const getInitialState = () => ({});

    export class Main extends React.Component {
        state = getInitialState();
        send = makeSend.call(this, reducers, effects);

        render() {
            return (
                <AppContext.Provider value={[this.state, this.send, A]}>
                    <MyApp />
                </AppContext>
            )
        }
    }

    // ...

    // Use it downstream somewhere (hook example):
    import {AppContext} from './some/place';

    export const MyComponent = () => {
        // Component state:
        const [state, send] = useSend(reducers, effects, getInitialState());
        // Global state (with different var names):
        const [gState, gSend, GA] = React.useContext(AppContext);

        return (<p>...</p>)
    }
**/
function makeSend(reducers, effects = {}) {
    if (this == null) {
        throw new Error(`makeSend missing 'this' did you invoke with call(this)?`);
    }

    if (!_.isObject(reducers)) {
        throw new Error(`reducers argument not type object, got '${typeof reducers}'`);
    }

    if (!_.isObject(effects)) {
        throw new Error(`effects argument not type object, got '${typeof effects}'`);
    }

    const reducer = makeReducer(reducers);

    // TODO: is curry helpful or confusing?
    const send = _.curry((type, payload = {}) => {
        const action = {type, payload};

        if (action.type == null) {
            throw new Error(`Action 'type' key is nullish. Did you forget to create an action constant?`);
        }

        // Automatically persist any React synthetic events.
        payload?.persist?.();

        // If payload is an event object try to extract form data or input data as the payload.
        if (action.payload instanceof Event || action.payload?.nativeEvent instanceof Event) {
            action.event = action.payload;

            if (action.event?.target instanceof HTMLFormElement) {
                action.payload = Object.fromEntries(new FormData(action.event.target));
            } else {
                const name = action.event?.target?.name;
                const value = action.event?.target?.type === 'checkbox' ? action.event?.target?.checked : action?.event?.target?.value;

                if (name !== '' && name !== undefined && value !== undefined) {
                    action.payload = {[name]: value};
                }
            }
        }

        const thunk = effects[action.type];
        if (thunk != null) {
            if (!_.isFunction(thunk)) {
                throw new Error(`Thunk for '${action.type}' is not a function.`);
            }

            if (DEBUG === true) {
                // eslint-disable-next-line no-console
                console.debug(action.type, {action});
            }

            const ret = thunk(send, action, requestDeps, this.state);
            // Make sure we return a Promise even if the thunk does not.
            return ret instanceof Promise ? ret : Promise.resolve(ret);
        }

        return new Promise((res) =>
            this.setState(
                (oldState) => {
                    const newState = reducer(oldState, action);
                    if (DEBUG === true) {
                        // eslint-disable-next-line no-console
                        console.debug(action.type, {
                            action,
                            oldState,
                            newState: newState !== oldState ? newState : '<State unchanged.>',
                        });

                        if (action.payload instanceof Error) {
                            // eslint-disable-next-line no-console
                            console.error(action.type, action.payload);
                        }
                    }
                    return newState;
                },
                function () {
                    res(this.state);
                },
            ),
        );
    }, 2);

    return send;
}

/**
Same as makeSend() above but as a hook for function components

Usage:

    const MyComponent = (props) => {
        const [state, send] = useSend(reducers, effects, getInitialState())

        return <p>Hello, {state.name}.</p>
    }
**/
const noop = () => {};

const useSend = (reducers, effects = {}, initialState) => {
    const [state, setState] = React.useState(initialState);
    const resolve = React.useRef(noop);

    const send = React.useMemo(() => {
        // The Hook setState doesn't support the second argument. To mimic it
        // we need to have useEffect trigger the Promise resolution.
        const _setState = (fnOrObj, cbFn) => {
            resolve.current = cbFn;
            setState(fnOrObj);
        };

        return makeSend.call({state, setState: _setState}, reducers, effects);
    }, []);

    React.useEffect(() => {
        // Mimic the React API and make sure `this.state` is populated when
        // invoking the callback function.
        resolve.current.call({state});
        resolve.current = noop;
    }, [state]);

    return [state, send];
};

/**
Shorthand for creating an object with duplicate key/val pairs

Usage:

    const ACTIONS = objMirror([
        'FOO', 'BAR', 'BAZ',
    ])
    // => {'FOO': 'FOO', 'BAR': 'BAR', 'BAZ': 'BAZ'}
**/
const objMirror = (xs) =>
    xs.reduce((acc, cur) => {
        acc[cur] = cur;
        return acc;
    }, {});