Last active
February 10, 2021 14:06
-
-
Save ericzakariasson/49684a7a7f10e6e0604ec7e96f4b76a7 to your computer and use it in GitHub Desktop.
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, { useCallback, useEffect, useRef, useState } from 'react' | |
// Types to be shared with frontend and backend | |
interface ApiResponseInfo { | |
code: number | |
message: string | |
[key: string]: unknown | |
} | |
interface ApiResponseCommon { | |
info: ApiResponseInfo | |
} | |
export interface ApiResponseSuccess<T extends unknown> extends ApiResponseCommon { | |
success: true | |
data: T | |
} | |
export interface ApiResponseError extends ApiResponseCommon { | |
success: false | |
errors: unknown[] | |
storeErrorCode?: unknown | |
storeErrorMessage?: unknown | |
internalCode?: unknown | |
reqId?: string | |
} | |
export type ApiResponse<T = unknown> = ApiResponseSuccess<T> | ApiResponseError | |
const initialApiState: ApiResponseError = { | |
success: false, | |
info: { code: 0, message: '' }, | |
errors: [], | |
} | |
// Api helper | |
interface ApiRequestOptions { | |
abortSignal: AbortSignal | |
} | |
function createApiRequest<T>(path: string) { | |
return async function getApi(options: ApiRequestOptions) { | |
const response = await fetch(path, { | |
signal: options.abortSignal, | |
}) | |
const json: ApiResponse<T> = await response.json() | |
return json | |
} | |
} | |
interface UseApiOptions<T> { | |
onSuccess?: (response: ApiResponseSuccess<T>) => void | |
onError?: (response: ApiResponseError) => void | |
onCatch?: (error: unknown) => void | |
} | |
type UseApiExecutor<TData, TArgs extends unknown[]> = ( | |
...args: TArgs | |
) => (options: ApiRequestOptions) => Promise<ApiResponse<TData>> | |
type UseLazyApiReturn<TData, TArgs extends unknown[]> = [ | |
ApiResponse<TData>, | |
boolean, | |
(...args: TArgs) => Promise<void> | |
] | |
// React Hooks | |
function useLazyApi<TData, TArgs extends unknown[]>( | |
getExecutor: UseApiExecutor<TData, TArgs>, | |
options: UseApiOptions<TData> = {} | |
): UseLazyApiReturn<TData, TArgs> { | |
const [isLoading, setLoading] = useState(false) | |
const [result, setResult] = useState<ApiResponse<TData>>(initialApiState) | |
// This is so we don't set state if the hook is unmounted | |
const unmountRef = useRef(false) | |
const isUnMounted = unmountRef.current | |
const controller = new AbortController() | |
const execute = useCallback( | |
async (...args: TArgs) => { | |
try { | |
setLoading(true) | |
const executor = getExecutor(...args) | |
const result = await executor({ | |
abortSignal: controller.signal, | |
}) | |
if (result.success) { | |
options.onSuccess?.(result) | |
} else { | |
options.onError?.(result) | |
} | |
if (!isUnMounted) { | |
setResult(result) | |
} | |
} catch (e) { | |
// Handle other uncaught errors | |
options.onCatch?.(e) | |
} finally { | |
if (!isUnMounted) { | |
setLoading(false) | |
} | |
} | |
}, | |
[getExecutor] | |
) | |
useEffect(() => { | |
return () => { | |
// Will abort request when unmounted | |
controller.abort() | |
unmountRef.current = true | |
} | |
}, []) | |
return [result, isLoading, execute] | |
} | |
type UseApiReturn<T> = [ApiResponse<T>, boolean, () => void] | |
function useApi<TData, TArgs extends unknown[]>( | |
executor: UseApiExecutor<TData, TArgs>, | |
args: TArgs, | |
options: UseApiOptions<TData> = {} | |
): UseApiReturn<TData> { | |
const [result, isLoading, execute] = useLazyApi(executor, options) | |
const refresh = useCallback(() => { | |
execute(...args) | |
}, [execute]) | |
// Execute on each update | |
useEffect(refresh, [refresh]) | |
return [result, isLoading, refresh] | |
} | |
// How a component could look | |
// Would import this type from shared type package | |
interface PatientDto { | |
patientId: string | |
} | |
// Would be defined in some `api.ts` file | |
const getPatientById = (id: string) => createApiRequest<PatientDto>(`api/patients/${id}`) | |
interface ExampleProps { | |
patientId: string | |
} | |
const Example: React.FC<ExampleProps> = ({ patientId }) => { | |
// Will fetch on mount | |
const [result, isLoading] = useApi( | |
getPatientById, | |
[patientId] /* Typed arguments from the executor */, | |
{ | |
onSuccess: ({ data }) => console.log('✅ Received patient with id', data.patientId), | |
// this will handle "soft" errors, e.g. validation errors from the backend | |
onError: ({ errors }) => console.log('😞 Got some errors', errors), | |
// This will only fire for uncaught errors, e.g. network errors, errors thrown inside the executor | |
onCatch: () => console.log('🚨 Caught an error'), | |
// Could also pass in other options we want, like custom headers or something else | |
} | |
) | |
if (isLoading) { | |
return <div>Loading...</div> | |
} | |
// `response.success` is the union discriminator | |
if (result.success === true) { | |
// Data exist if seuccessful | |
return <div>{result.data.patientId}</div> | |
} | |
if (result.success === false) { | |
// Errors exist if not successful | |
return <div>{result.errors}</div> | |
} | |
return null | |
} | |
const ExampleLazy: React.FC<ExampleProps> = ({ patientId }) => { | |
// This will execute when `getPatient` is invoked | |
const [result, isLoading, getPatient] = useLazyApi(getPatientById) | |
return ( | |
<div> | |
<button onClick={() => getPatient(patientId)} disabled={isLoading}> | |
{isLoading ? 'Loading...' : 'Click to fetch from api'} | |
</button> | |
{result.success && <h1>{result.data.patientId}</h1>} | |
</div> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Improvements
AbortController
when return function ofuseEffect
is invokedFor discussion