Skip to content

Instantly share code, notes, and snippets.

@ericzakariasson
Last active February 10, 2021 14:06
Show Gist options
  • Save ericzakariasson/49684a7a7f10e6e0604ec7e96f4b76a7 to your computer and use it in GitHub Desktop.
Save ericzakariasson/49684a7a7f10e6e0604ec7e96f4b76a7 to your computer and use it in GitHub Desktop.
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>
)
}
@ericzakariasson
Copy link
Author

ericzakariasson commented Feb 10, 2021

Improvements

  • Abort request with AbortController when return function of useEffect is invoked
  • Initial state should not reflect error state

For discussion

  • Return object instead of tuple
  • Pass in parameters as object instead of array

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment