Created
May 27, 2022 15:44
-
-
Save aszenz/29771d74af559b4d3f753ad2abb2bfa2 to your computer and use it in GitHub Desktop.
React Declarative side effects (useReducerWithEffects)
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
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