import React from 'react'; import { isUndefined, isString } from '@aws-amplify/ui'; export const INVALID_OPTIONS_MESSAGE = 'an `errorMessage` or a `defaultValue` must be provided in `options`'; interface OptionsWithDefaultValue { contextName: Name; defaultValue: ContextType; // exclude `errorMessage` if `defaultValue` is present errorMessage?: never; } interface OptionsWithErrorMessage { contextName: Name; errorMessage: ErrorMessage; // exclude `defaultValue` if `errorMessage` is present defaultValue?: never; } type ContextOptions = | OptionsWithDefaultValue | OptionsWithErrorMessage; type HookParams = { errorMessage?: string }; type UtilityKey = | `${ContextName}Provider` | `use${ContextName}` | `${ContextName}Context`; type CreateContextUtilitiesReturn = { [Key in UtilityKey]: Key extends `${string}Provider` ? React.ComponentType> : Key extends `use${string}` ? (params?: HookParams) => ContextType : Key extends `${string}Context` ? React.Context : never; }; /** * Uses `ContextType`/`Name` generics and `options` to create: * - `${Name}Context`: React Context of type `ContextType` * - `Provider${Name}`: React Context `Provider` component exposing the `ContextType` * as optional props * - `use${Name}`: Utility Hook exposing the values of `ContextType`. Allows * params with `errorMessage` for granular error messaging * * @template ContextType Type definition of the Context. * > For most use cases the keys of `ContextType` should not be optional in * preference of explicit `undefined` to avoid optional types on the * Utility Hook return * * @param options Context utility options. Requires a `contextName`, and * either a `defaultValue` of `ContextType` **or** an `errorMessage` * allowing for differing behaviors of the Utility Hook when used outside a * parent `Provider`: * * - `defaultValue`: Ensures the Utility Hook returns a default value for * scenarios **where the missing context values should not impact usage** * - `errorMessage`: Ensures the Utility Hook throws an error for * scenarios **where the missing context values should prevent** usage * * @returns `Context`, `Provider` Component and `useContext` Utility Hook * * @usage * ```ts * interface StuffContextType { * things: number; * } * * // with `defaultValue` * const defaultValue: StuffContextType = { things: 7 }; * * const { StuffProvider, useStuff } = createContextUtilities({ * contextName: 'Stuff', * defaultValue, * }); * * // with `errorMessage` * const { StuffProvider, useStuff } = createContextUtilities({ * contextName: 'Stuff', * errorMessage: '`useStuff` must be used in a `StuffProvider`' * }); * ``` */ export default function createContextUtilities< ContextType, ContextName extends string = string, Message extends string | undefined = string | undefined, >( options: ContextOptions ): CreateContextUtilitiesReturn { const { contextName, defaultValue, errorMessage } = options ?? {}; if (isUndefined(defaultValue) && !isString(errorMessage)) { throw new Error(INVALID_OPTIONS_MESSAGE); } const contextDisplayName = `${contextName}Context`; const providerDisplayName = `${contextName}Provider`; const Context = React.createContext(defaultValue); Context.displayName = contextDisplayName; function Provider(props: React.PropsWithChildren) { const { children, ...context } = props; const value = React.useMemo( () => context, // Unpack `context` for the dep array; using `[context]` results in // evaluation on every render // eslint-disable-next-line react-hooks/exhaustive-deps Object.values(context) ) as ContextType; return {children}; } Provider.displayName = providerDisplayName; return { [`use${contextName}`]: function (params?: HookParams) { const context = React.useContext(Context); if (isUndefined(context)) { throw new Error(params?.errorMessage ?? errorMessage); } return context; }, [providerDisplayName]: Provider, [contextDisplayName]: Context, } as CreateContextUtilitiesReturn; }