useFetch hook (with optional view transition)

Had to use a .d.ts and .js file to get all the types to align and make all the tools happy, IDE/eslint/TypeScript.
This commit is contained in:
Andreas Svanberg 2025-04-06 17:38:51 +02:00
parent 6542cee415
commit f06a7381e7
Signed by: ansv7779
GPG Key ID: 729B051CFFD42F92
3 changed files with 109 additions and 46 deletions
frontend/src

@ -1,57 +1,21 @@
import { useEffect, useState } from "react";
import "./App.css";
import { useBackend } from "./hooks/backend.ts";
import { paths } from "./lib/api";
import suLogoLandscape from "./assets/SU_logo_optimized.svg";
import { ProfileContext } from "./hooks/profile.ts";
import Studentportalen from "./Studentportalen.tsx";
type Profile =
paths["/profile"]["get"]["responses"]["200"]["content"]["application/json"];
import { useViewTransitioningFetch } from "./hooks/fetch";
function App() {
const { client } = useBackend();
const [state, setState] = useState<
"initializing" | "authenticating" | "error" | Profile
>("initializing");
const { data: profile, error } = useViewTransitioningFetch("/profile");
useEffect(() => {
const controller = new AbortController();
withViewTransition(() => {
return client
.GET("/profile", { signal: controller.signal })
.then(({ data, response }) => {
if (data) {
setState(data);
}
if (response.status === 401) {
setState("authenticating");
}
})
.catch((error) => {
if (error.name !== "AbortError") {
setState("error");
}
});
});
return () => {
controller.abort();
};
}, [client]);
if (state === "initializing") {
if (!profile) {
return splashScreen("Loading...");
}
if (state === "authenticating") {
return splashScreen("Logging in...");
}
if (state === "error") {
if (error) {
return splashScreen("Application failed to start");
}
return (
<ProfileContext value={state}>
<ProfileContext value={profile}>
<Studentportalen />
</ProfileContext>
);
@ -70,9 +34,4 @@ function splashScreen(extraContent: string) {
);
}
function withViewTransition(f: () => Promise<unknown>): void {
if (document.startViewTransition) void document.startViewTransition(f);
else void f();
}
export default App;

39
frontend/src/hooks/fetch.d.ts vendored Normal file

@ -0,0 +1,39 @@
// Using a .js & .d.ts file to make the types work well at use sites
// and make TypeScript, IDE, and eslint happy.
//
// Important to use `useMemo` on any options object passed in
// since it is used as a dependency to re-fetch
import {
PathsWithMethod,
type RequiredKeysOf,
} from "openapi-typescript-helpers";
import { paths } from "../lib/api";
import { FetchResponse, MaybeOptionalInit } from "openapi-fetch";
type InitParam<Init> =
RequiredKeysOf<Init> extends never
? [(Init & { [key: string]: unknown })?]
: [Init & { [key: string]: unknown }];
export function useViewTransitioningFetch<
Path extends PathsWithMethod<paths, "get">,
Init extends MaybeOptionalInit<paths[Path], "get">,
>(
path: Path,
...init: InitParam<Init>
): Omit<
FetchResponse<paths[Path]["get"], Init, "application/json">,
"response"
>;
export function useFetch<
Path extends PathsWithMethod<paths, "get">,
Init extends MaybeOptionalInit<paths[Path], "get">,
>(
path: Path,
...init: InitParam<Init>
): Omit<
FetchResponse<paths[Path]["get"], Init, "application/json">,
"response"
>;

@ -0,0 +1,65 @@
// Using a .js & .d.ts file to make the types work well at use sites
// and make TypeScript, IDE, and eslint happy.
//
// Important to use `useMemo` on any options object passed in
// since it is used as a dependency to re-fetch
import { useBackend } from "./backend.ts";
import { useEffect, useState } from "react";
export function useFetch(path, options) {
const { client } = useBackend();
const [response, setResponse] = useState({});
useEffect(() => {
const abortController = new AbortController();
void doFetch(client, path, options, abortController, setResponse);
return () => {
abortController.abort();
};
}, [client, path, options]);
return { data: response.data, error: response.error };
}
export function useViewTransitioningFetch(path, options) {
const { client } = useBackend();
const [response, setResponse] = useState({});
useEffect(() => {
const abortController = new AbortController();
withViewTransition(function () {
return doFetch(client, path, options, abortController, setResponse);
});
return () => {
abortController.abort();
};
}, [client, path, options]);
return { data: response.data, error: response.error };
}
async function doFetch(client, path, options, abortController, setResponse) {
try {
const response = await client.GET(path, {
...options,
signal: abortController.signal,
});
setResponse(response);
} catch (error) {
if (abortController.signal.aborted) {
return;
}
setResponse({ error });
}
}
function withViewTransition(f) {
if (document.startViewTransition) void document.startViewTransition(f);
else void f();
}