Skip to content

Instantly share code, notes, and snippets.

@whiteinge
Last active July 27, 2023 10:29
Show Gist options
  • Save whiteinge/1b796d1ae7e1d0eb1457897a95db4a82 to your computer and use it in GitHub Desktop.
Save whiteinge/1b796d1ae7e1d0eb1457897a95db4a82 to your computer and use it in GitHub Desktop.
Redux and redux-thunk implemented as component-state, or hook-state, and/or context-state
<!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>
/**
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;
}, {});
@whiteinge
Copy link
Author

whiteinge commented Oct 19, 2022

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment