Skip to content

Instantly share code, notes, and snippets.

@jsamr
Last active June 29, 2022 14:06
Show Gist options
  • Save jsamr/ac6f958003dae8053248be8450d1c79b to your computer and use it in GitHub Desktop.
Save jsamr/ac6f958003dae8053248be8450d1c79b to your computer and use it in GitHub Desktop.
Explicit Finite State Machine Pattern
import { useCallback, useEffect, useReducer } from "react";
type FetchTransition<P> =
| {
type: "fetch";
}
| {
type: "fetchOK";
data: P;
}
| {
type: "fetchError";
error: Error;
};
type FetchState<P> =
| DryState
| DryErrorState
| FetchingState
| RefetchingState<P>
| FreshState<P>
| StaleState<P>;
interface DryState {
data: null;
error: null;
isLoading: false;
type: "dry";
}
interface DryErrorState {
data: null;
error: Error;
isLoading: false;
type: "dryError";
}
interface FetchingState {
data: null;
error: null;
isLoading: true;
type: "fetching";
}
interface RefetchingState<P> {
data: P;
error: null;
isLoading: true;
type: "refetching";
}
interface FreshState<P> {
data: P;
error: null;
isLoading: false;
type: "fresh";
}
interface StaleState<P> {
data: P;
error: Error;
isLoading: false;
type: "stale";
}
export const stateFactory = {
dry(): DryState {
return {
data: null,
error: null,
isLoading: false,
type: "dry",
};
},
dryError(error: Error): DryErrorState {
return {
data: null,
error,
isLoading: false,
type: "dryError",
};
},
fetching(): FetchingState {
return {
data: null,
error: null,
isLoading: true,
type: "fetching",
};
},
fresh<P>(data: P): FreshState<P> {
return {
data,
error: null,
isLoading: false,
type: "fresh",
};
},
refetching<P>(cachedData: P): RefetchingState<P> {
return {
data: cachedData,
error: null,
isLoading: true,
type: "refetching",
};
},
stale<P>(error: Error, cachedData: P): StaleState<P> {
return {
data: cachedData,
error,
isLoading: false,
type: "stale",
};
},
};
export function fetchReducer<P>(
state: FetchState<P>,
transition: FetchTransition<P>
): FetchState<P> {
if (state.type === "dry" && transition.type === "fetch") {
return stateFactory.fetching();
}
if (state.type === "dryError" && transition.type === "fetch") {
return stateFactory.fetching();
}
if (state.type === "fetching" && transition.type === "fetchOK") {
return stateFactory.fresh(transition.data);
}
if (state.type === "fetching" && transition.type === "fetchError") {
return stateFactory.dryError(transition.error);
}
if (state.type === "fresh" && transition.type === "fetch") {
return stateFactory.refetching(state.data);
}
if (state.type === "refetching" && transition.type === "fetchOK") {
return stateFactory.fresh(transition.data);
}
if (state.type === "refetching" && transition.type === "fetchError") {
return stateFactory.stale(transition.error, state.data);
}
if (state.type === "stale" && transition.type === "fetch") {
return stateFactory.refetching(state.data);
}
console.warn(
`Unauthorized transition ${transition.type} while in state ${state.type}`
);
return state;
}
export default function useFetch<P>({
url,
autoFetch,
}: {
url: string;
autoFetch: boolean;
}) {
const [state, dispatch] = useReducer(fetchReducer, void 0, stateFactory.dry);
const launchFetch = useCallback(() => {
if (!url) return;
dispatch({ type: "fetch" });
}, [url]);
useEffect(
function runFetch() {
let canceled = false;
if (state.isLoading) {
(async () => {
try {
const response = await fetch(url);
if (canceled) return;
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
if (canceled) return;
dispatch({ type: "fetchOK", data });
} catch (error) {
if (canceled) return;
dispatch({ type: "fetchError", error });
}
})();
}
return () => {
canceled = true;
};
},
[url, state.isLoading]
);
useEffect(() => {
if (autoFetch) {
launchFetch();
}
}, [autoFetch, launchFetch]);
return { state: state as FetchState<P>, fetch };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment