Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save aszenz/29771d74af559b4d3f753ad2abb2bfa2 to your computer and use it in GitHub Desktop.
Save aszenz/29771d74af559b4d3f753ad2abb2bfa2 to your computer and use it in GitHub Desktop.
React Declarative side effects (useReducerWithEffects)
import React from "react";
type Reducer<State, Action, Effect> = (
state: State,
action: Action
) => [State, Effect];
type EffectHandler<Effect, Action> = (effect: Effect) => Promise<void | Action>;
type StateWithEffect<State, Effect> = [State, Effect];
function useReducerWithEffects<State, Action, Effect>(
update: Reducer<State, Action, Effect>,
effectHandler: EffectHandler<Effect, Action>,
stateWithEffect: StateWithEffect<State, Effect>
): [State, (action: Action) => void] {
const updater = (
[initialState, _]: StateWithEffect<State, Effect>,
action: Action
) => update(initialState, action);
const [[state, effect], dispatch] = React.useReducer(
updater,
stateWithEffect
);
React.useEffect(() => {
effectHandler(effect).then((effectResult) =>
effectResult === undefined ? undefined : dispatch(effectResult)
);
}, [effect]);
return [state, dispatch];
}
// Example App using this pattern
function App() {
const [{ count, apiData }, dispatch] = useReducerWithEffects(
update,
effectManager,
getInitialStateWithEffect()
);
return (
<div>
Count is: <b>{count}</b>
<br />
<button onClick={() => dispatch({ type: "ClickedIncrement" })}>
Increment +
</button>
<button onClick={() => dispatch({ type: "ClickedDecrement" })}>
Decrement -
</button>
<br />
<button onClick={() => dispatch({ type: "ClickedFetchApiData" })}>
Fetch data from api
</button>
<br />
Api result:
{(() => {
switch (apiData.type) {
case "NotAsked": {
return <div>Not Run</div>;
}
case "Error": {
return <div>Api error</div>;
}
case "Loading": {
return <div>Loading...</div>;
}
case "Success": {
return (
<ul>
{apiData.data.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
default:
((_: never): never => {
throw new Error("Unexpected value");
})(apiData);
}
})()}
</div>
);
}
type State = {
count: number;
apiData:
| { type: "NotAsked" }
| { type: "Loading" }
| { type: "Error"; error: string }
| { type: "Success"; data: string[] };
};
type Action =
| { type: "ClickedIncrement" }
| { type: "ClickedDecrement" }
| { type: "ClickedFetchApiData" }
| { type: "ReceivedApiData"; apiData: string[] }
| { type: "ReceivedApiError"; apiError: string }
| { type: "EffectFailed"; reason: string };
type Effect =
| { type: "FetchApiData" }
| { type: "LogValue"; value: unknown }
| { type: "ShowAlert"; message: string }
| { type: "NoEffect" };
function update(state: State, action: Action): [State, Effect] {
const noEffect: Effect = { type: "NoEffect" };
switch (action.type) {
case "ClickedIncrement": {
return [{ ...state, count: state.count + 1 }, noEffect];
}
case "ClickedDecrement": {
return [{ ...state, count: state.count - 1 }, noEffect];
}
case "ClickedFetchApiData": {
return [
{ ...state, apiData: { type: "Loading" } },
{ type: "FetchApiData" },
];
}
case "ReceivedApiData": {
return [
{ ...state, apiData: { type: "Success", data: action.apiData } },
{ type: "LogValue", value: action.apiData },
];
}
case "ReceivedApiError": {
return [
{ ...state, apiData: { type: "Error", error: action.apiError } },
{ type: "LogValue", value: action.apiError },
];
}
case "EffectFailed": {
return [state, { type: "ShowAlert", message: action.reason }];
}
default:
((_: never): never => {
throw new Error("Missing action handler");
})(action);
}
}
function effectManager(effect: Effect): Promise<void | Action> {
const emptyPromise = new Promise<void>((res) => res());
return (() => {
switch (effect.type) {
case "FetchApiData": {
return fetchDataFromApi()
.then(
(apiData): Action => ({
type: "ReceivedApiData",
apiData,
})
)
.catch(
(apiError: string): Action => ({
type: "ReceivedApiError",
apiError,
})
);
}
case "LogValue": {
console.log("logged: ", effect.value);
return emptyPromise;
}
case "ShowAlert": {
window.alert(effect.message);
return emptyPromise;
}
case "NoEffect": {
return emptyPromise;
}
default:
((_: never): never => {
throw new Error("Missing effect handler");
})(effect);
}
})().catch(
(effectError: unknown): Action => ({
type: "EffectFailed",
reason: String(effectError),
})
);
}
function getInitialStateWithEffect(): [State, Effect] {
return [
{
count: 0,
apiData: { type: "NotAsked" },
},
{ type: "LogValue", value: "App Started" },
];
}
function fetchDataFromApi(): Promise<string[]> {
return new Promise((resolve, reject): void => {
const lucky = Math.random() > 0.2;
const data = ["Apples", "Bananas", "Pineapples"];
if (lucky) {
setTimeout(() => resolve(data), 2000);
return;
}
setTimeout(() => reject("Failed to fetch data"), 3000);
});
}
export default App;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment