Last active
July 27, 2023 10:29
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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/[email protected]/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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
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; | |
}, {}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Live demo on blocks.roadtolarissa.com