Skip to content

Instantly share code, notes, and snippets.

@TClark1011
Last active October 22, 2024 05:12
Show Gist options
  • Select an option

  • Save TClark1011/b053f49397e7ec309da6608280d9b72c to your computer and use it in GitHub Desktop.

Select an option

Save TClark1011/b053f49397e7ec309da6608280d9b72c to your computer and use it in GitHub Desktop.
React Utilities
import { ReactNode, SetStateAction, useEffect, PropsWithChildren } from "react";
export type StateSetter<T> = (p: SetStateAction<T>) => any;
export type ReactEffect = Parameters<typeof useEffect>[0];
export type WithClassName = {
className?: string;
};
export type WithStyle = {
style?: CSSProperties;
};
export type WithChildren = PropsWithChildren<Record<string, never>>;
//$ This type represents the name of a valid JSX element
//$ eg; "h1", "div", "p", etc.
export type JSXElementName = keyof JSX.IntrinsicElements;
export type WithAs = {
as?: JSXElementName;
};
import { Button, useColorMode } from '@chakra-ui/react';
import type { FC } from 'react';
const offset = 4;
type ChakraColorModeDevtoolProps = {
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
};
/**
* A button that toggles the color mode. Stays on top of
* everything else. Does not render in production.
*/
export const ChakraColorModeDevtool: FC<ChakraColorModeDevtoolProps> = ({
position = 'bottom-right',
}) => {
const { colorMode, toggleColorMode } = useColorMode();
if (process.env.NODE_ENV !== 'development') return null;
return (
<Button
size="lg"
position="fixed"
onClick={toggleColorMode}
boxShadow="lg"
zIndex={9999}
sx={{
[position.includes('bottom') ? 'bottom' : 'top']: offset,
[position.includes('right') ? 'right' : 'left']: offset,
}}
>
{colorMode === 'light' ? '☀️' : '🌙'}
</Button>
);
};
import type { ThemingProps } from '@chakra-ui/react';
import {
extendTheme,
ThemeProvider,
useTheme,
withDefaultColorScheme,
withDefaultSize,
withDefaultVariant,
} from '@chakra-ui/react';
import type { FC, PropsWithChildren } from 'react';
export type ChakraDefaultsProviderProps = PropsWithChildren<{
colorScheme?: ThemingProps['colorScheme'];
size?: ThemingProps<'Button'>['size'];
buttonVariant?: ThemingProps<'Button'>['variant'];
}>;
/**
* Utility for when you want multiple components to re-use the same
* theming variables, eg. size and color scheme.
*/
export const ChakraDefaultsProvider: FC<ChakraDefaultsProviderProps> = ({
colorScheme,
size,
children,
buttonVariant,
}) => {
const baseTheme = useTheme();
const themeWithDefaults = extendTheme(
baseTheme,
withDefaultColorScheme({
colorScheme,
}),
withDefaultSize({
size,
}),
withDefaultVariant({
variant: buttonVariant,
components: ['Button', 'IconButton', 'ButtonGroup'],
})
);
return <ThemeProvider theme={themeWithDefaults}>{children}</ThemeProvider>;
};
import {
Center,
Modal,
ModalContent,
ModalOverlay,
Spinner,
SpinnerProps,
Text,
} from '@chakra-ui/react';
import React from 'react';
export type LoadingOverlayProps = SpinnerProps & {
isOpen: boolean;
text?: string;
};
const LoadingOverlay = ({
isOpen,
text,
color = 'gray.50',
...spinnerProps
}: LoadingOverlayProps) => (
<Modal isOpen={isOpen} isCentered onClose={() => {}}>
<ModalOverlay />
<ModalContent as={Center} backgroundColor="transparent" shadow="none">
<Spinner color={color} size="xl" thickness="4px" {...spinnerProps} />
{text && (
<Text color={color} fontSize="lg" mt={2} textAlign="center">
{text}
</Text>
)}
</ModalContent>
</Modal>
);
export default LoadingOverlay;
import { isStyleProp, StyleProps } from "@chakra-ui/react";
type SplitProps<BaseObject extends StyleProps> = {
styleProps: Pick<BaseObject, keyof StyleProps>;
nonStyleProps: Omit<BaseObject, keyof StyleProps>;
};
/**
* Take a props object, and split it into two different objects,
* one containing only style props, and one containing all other
* props. This is useful if you are writing a custom component
* where you want all logic-related props to be passed to an
* inner component, but need the style props to be passed to the
* root component.
*/
const splitStyleProps = <BaseObject extends StyleProps>(
props: BaseObject,
): SplitProps<BaseObject> => {
const styleProps = {} as SplitProps<BaseObject>["styleProps"];
const nonStyleProps = {} as SplitProps<BaseObject>["nonStyleProps"];
Object.entries(props).forEach(([key, value]) => {
if (isStyleProp(key)) {
styleProps[key as keyof typeof styleProps] = value;
} else {
nonStyleProps[key as keyof typeof nonStyleProps] = value;
}
});
return {
styleProps,
nonStyleProps,
};
};
export default splitStyleProps;
import { useTabsContext } from '@chakra-ui/react';
// Can be used to check if a specific tab index
// is active within a `Tabs` instance
const useTabIndexIsSelected = (index: number): boolean => {
const { selectedIndex } = useTabsContext();
return selectedIndex === index;
};
export default useTabIndexIsSelected;
import React, { createContext, PropsWithChildren, useContext } from "react";
const emptyContextSymbol = Symbol("__EMPTY_CONTEXT__");
const createContextSuite = <Data extends Record<string, unknown>>() => {
const Context = createContext<Data | typeof emptyContextSymbol>(
emptyContextSymbol,
);
const useGeneratedContext = () => {
const data = useContext(Context);
if (data === emptyContextSymbol) {
throw new Error("Context not found");
}
return data;
};
const Provider = ({ children, ...value }: PropsWithChildren<Data>) => (
<Context.Provider value={value as Data}>{children}</Context.Provider>
);
return [Provider, useGeneratedContext] as const;
};
export default createContextSuite;
import { useMemo, useCallback, useLayoutEffect, useState } from "react";
type Size = {
width: number;
height: number;
};
export const useElementSize = () => {
const [element, elementRef] = useState<HTMLElement | null>(null);
// This uses a hack where the 'setter' from a 'useState' call can be passed
// as a ref to an element. This allows us to have a 'useEffect' call re-run
// when an element is changed, which is not possible when using a basic
// 'ref'
const [size, setSize] = useState<Size>({
width: 0,
height: 0,
});
const updateSize = useCallback((): Size => {
const newSize: Size = {
width: element?.offsetWidth || 0,
height: element?.offsetHeight || 0,
};
setSize(newSize);
return newSize;
}, [element]);
useLayoutEffect(() => {
// Compute size when page layout changes
updateSize();
}, [updateSize]);
return [elementRef, size, updateSize] as const;
};
/**
* Generates dynamic CSS variables you can pass to an elements
* style that track its current size. You can then access these
* from within style code.
*/
export const useElementSizeStyleVariables = (elementName = "element") => {
const [elementRef, { width, height }, recomputeSizeVars] = useElementSize();
const sizeVariables = useMemo(
() =>
({
[`--${elementName}-width`]: `${width}px`,
[`--${elementName}-height`]: `${height}px`,
} as any),
[width, height, elementName],
);
return [elementRef, sizeVariables, recomputeSizeVars] as const;
};

Event Functions In React

When writing functions that can handle various different types of dom events in react, it's important to remember that react uses its own custom type of events which it calls "synthetic events". To best handle these, we create a custom type which can be either a native event or a react synthetic event.

Example code

import type { SyntheticEvent } from 'react';

type ReactCompatibleEvent = Event | SyntheticEvent;

export const withStoppedPropagation =
  <SpecificEvent extends ReactCompatibleEvent, ReturnType>(
    handler: (e: SpecificEvent) => ReturnType
  ) =>
  (e: SpecificEvent) => {
    e.stopPropagation();
    return handler(e);
  };

export const withPreventedDefault =
  <SpecificEvent extends ReactCompatibleEvent, ReturnType>(
    handler: (e: SpecificEvent) => ReturnType
  ) =>
  (e: SpecificEvent) => {
    e.preventDefault();
    return handler(e);
  };
import { FC } from 'react';
export type MetaProps = {
title?: string;
url?: string;
description?: string; //Should be no longer than 200 characters
image?: string;
};
// If using react < 18, apply the prop type to the parameter instead
// of using `FC`.
// NOTE: At time of writing this does not actually work with NextJS
// because NextJS' `Head` component has very specific requirements
// in how meta tags are written within it. You can still use this
// as a reference for what meta tags to include in a page, and
// hopefully further down the line the NextJS `Head` component
// will be changed to allow this to work.
const Meta: FC<MetaProps> = ({ title, url, description, image }) => (
<>
{title && (
<>
<title>{title}</title>
<meta property="og:title" content={title} />
</>
)}
{description && (
<>
<meta name="description" content={description} />
<meta name="og:description" content={description} />
</>
)}
{image && <meta name="og:image" content={image} />}
{url && <meta name="og:url" content={url} />}
</>
);
export default Meta;
import React, { HTMLAttributes, forwardRef } from 'react';
type JSXElementName = keyof JSX.IntrinsicElements;
type VisuallyHiddenProps = {
as?: JSXElementName;
} & HTMLAttributes<any>;
/**
* The children of this component will not be visible to
* the end user, but will be visible to technologies such
* as screen readers and web crawlers. This allows you to
* follow the practices of semantic markup without
* compromising on design.
*
* @example
* <section>
* <VisuallyHidden as="h2">Name of Section</VisuallyHidden>
* <Content />
* </section>
*/
const VisuallyHidden = forwardRef<HTMLElement, VisuallyHiddenProps>(
({ children, style = {}, as: As = 'span', ...props }, ref) => (
<As
{...({ ref: ref as any } as any)} // ? We have to do this because Typescript is silly
style={{
border: 0,
clip: 'rect(0px, 0px, 0px, 0px)',
height: 1,
width: 1,
margin: -1,
padding: 0,
overflow: 'hidden',
whiteSpace: 'nowrap',
position: 'absolute',
...style,
}}
{...props}
>
{children}
</As>
)
);
VisuallyHidden.displayName = 'VisuallyHidden';
export default VisuallyHidden;
import { MantineColor, MantineThemeColors, useMantineTheme } from '@mantine/core';
type ShadeIndex = keyof MantineThemeColors[MantineColor];
const useThemeColor = (colorName: MantineColor | 'primary', shadeIndex?: ShadeIndex): string => {
const { colors, primaryShade, colorScheme, primaryColor } = useMantineTheme();
const colorNameToUse = colorName === 'primary' ? primaryColor : colorName;
const fallbackShade: ShadeIndex =
typeof primaryShade === 'number' ? primaryShade : primaryShade[colorScheme];
return colors[colorNameToUse][shadeIndex ?? fallbackShade] as string;
};
export default useThemeColor;
import { MantineTheme, useMantineTheme } from '@mantine/core';
const useThemeSelector = <T>(selector: (p: MantineTheme) => T): T => {
const theme = useMantineTheme();
return selector(theme);
};
export default useThemeSelector;

My Understanding of Deferred Values

This is my extremely basic understanding of deferred values based purely on my own experimentation and observations, and reading the docs of course.

So basically, if you have any conditional rendering like this:

<YourComponent>
{condition && <ConditionalComponent />}

When the condition state becomes true, the rendering logic for <ConditionalComponent /> will run, and while that is happening, all of the UI is blocked.

If the conditionally rendered content relies on a "deferred" value then it seems like the UI rendering gets pre-computed in the background and the "deferred" value only changes once that pre-computation is done. This means that the UI may have a slight delay to it, but it will not be blocked. It's also worth considering that if there is a noticeable delay, it means that without a deferred value there would still be a delay, except the UI would be totally frozen for that delay.

import { useEffect, useRef } from "react";
const useIsSSRRef = () => {
const isSSRRef = useRef(true);
useEffect(() => {
// Since SSR does not execute `useEffect` calls, this will
// won't be run unless its on the client
isSSRRef.current = false;
}, []);
return isSSRRef;
};
export default useIsSSR;
import { useRouter } from "next/router";
import { useEffect } from "react";
const useNavigationEffect = (effect: Parameters<typeof useEffect>[0]) => {
const { asPath } = useRouter();
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, [asPath]);
};
export default useNavigationEffect;
import {
useQuery as useBaseQuery,
QueryKey,
UseQueryOptions,
useQueryClient,
} from "@tanstack/react-query";
type AllowedDefaultOptions<Data> = Pick<
UseQueryOptions<Data>,
| "retry"
| "behavior"
| "staleTime"
| "cacheTime"
| "retryDelay"
| "context"
| "initialData"
| "onError"
| "refetchOnMount"
| "refetchOnWindowFocus"
| "refetchOnReconnect"
| "networkMode"
| "suspense"
| "useErrorBoundary"
| "meta"
| "getNextPageParam"
| "getPreviousPageParam"
| "placeholderData"
| "retryOnMount"
| "structuralSharing"
| "initialDataUpdatedAt"
| "notifyOnChangeProps"
| "keepPreviousData"
| "refetchIntervalInBackground"
> &
Partial<{
refetchInterval: false | number;
onSuccess: () => void;
onSettled: () => void;
}>;
export const createQuery = <Data, Params extends unknown[]>(
deriveKey: (...p: Params) => QueryKey,
fetcher: (...p: Params) => Promise<Data>,
unscopedDefaults: AllowedDefaultOptions<Data> = {},
) => {
const getKey = deriveKey;
const getOptions = <Derivation = Data>(
params: Params,
extraOptions: Omit<
UseQueryOptions<Data, unknown, Derivation>,
"queryKey" | "queryFn"
> = {},
): UseQueryOptions<Data, unknown, Derivation> => ({
queryKey: deriveKey(...params),
queryFn: () => fetcher(...params),
...unscopedDefaults,
...extraOptions,
onSuccess: (...p) => {
unscopedDefaults.onSuccess?.();
extraOptions.onSuccess?.(...p);
},
onError: (...p) => {
unscopedDefaults.onError?.(...p);
extraOptions.onError?.(...p);
},
onSettled: (...p) => {
unscopedDefaults.onSettled?.();
extraOptions.onSettled?.(...p);
},
});
const useQuery = <Derivation = Data>(
params: Params,
extraOptions: Omit<
UseQueryOptions<Data, unknown, Derivation, QueryKey>,
"queryKey" | "queryFn"
> = {},
) =>
useBaseQuery(deriveKey(...params), () => fetcher(...params), extraOptions);
const useHelpers = () => {
const queryClient = useQueryClient();
return {
updateData: (
params: Params,
updater: (d: Data | undefined) => Data | undefined,
) => queryClient.setQueryData(deriveKey(...params), updater),
invalidate: (...params: Params) =>
queryClient.invalidateQueries(deriveKey(...params)),
refetch: (...params: Params) =>
queryClient.refetchQueries(deriveKey(...params)),
remove: (...params: Params) =>
queryClient.removeQueries(deriveKey(...params)),
};
};
return { getOptions, useQuery, useHelpers, getKey };
};
import { A } from '@mobily/ts-belt';
import { stringify as queryStringify } from 'querystring';
import { useParams } from 'react-router-dom';
import { ConditionalKeys, Opaque, Schema } from 'type-fest';
export type WithLooseAutocomplete<
AcceptedType,
Suggestions extends AcceptedType
> =
| Suggestions
| Exclude<Omit<AcceptedType, Suggestions & keyof AcceptedType>, Suggestions>;
type RequiredParam = Opaque<string, 'requiredParam'>;
type OptionalParam = Opaque<string, 'optionalParam'>;
type ParamFieldDefinition = RequiredParam | OptionalParam;
type PartialByValue<Obj, ValuesToMakeOptional> = Omit<
Obj,
ConditionalKeys<Obj, ValuesToMakeOptional>
> &
Partial<Pick<Obj, ConditionalKeys<Obj, ValuesToMakeOptional>>>;
type ParamObject<
ParamDefinition extends Record<string, ParamFieldDefinition>
> = Schema<PartialByValue<ParamDefinition, OptionalParam>, string>;
export type RouteDefinition<
DynamicSegmentName extends string,
ParamsDefinition extends Record<string, ParamFieldDefinition> = Record<
never,
unknown
>
> = {
path: string;
generatePath: (
segmentValues: Record<DynamicSegmentName, string>,
paramValues: ParamObject<ParamsDefinition>
) => string;
extend: <
ExtendedDynamicSegmentName extends string,
ExtendedParams extends Record<
string,
RequiredParam | OptionalParam
> = Record<never, unknown>
>(
extensionSegmentValues: WithLooseAutocomplete<
string,
`:${DynamicSegmentName | ExtendedDynamicSegmentName}`
>[]
) => RouteDefinition<
DynamicSegmentName | ExtendedDynamicSegmentName,
ExtendedParams
>;
useParams: () => Partial<
ParamObject<ParamsDefinition> & Record<DynamicSegmentName, string>
>;
};
// Infer the type of an existing route
// definition's params
export type InferRouteParams<
RouteDef extends RouteDefinition<any, any>
> = RouteDef extends RouteDefinition<any, infer ParamDef>
? ParamObject<ParamDef>
: never;
// Infer the type of an existing route
// definition's dynamic segments
export type InferRouteDynamicSegments<
RouteDef extends RouteDefinition<any, any>
> = RouteDef extends RouteDefinition<infer SegmentName, any>
? SegmentName
: never;
const composePath = A.join('/');
const defineRoute = <
DynamicSegmentName extends string,
ParamsDefinition extends Record<string, ParamFieldDefinition> = Record<
never,
unknown
>
>(
segments: WithLooseAutocomplete<string, `:${DynamicSegmentName}`>[]
): RouteDefinition<DynamicSegmentName, ParamsDefinition> =>
({
path: composePath(segments),
generatePath: (segmentValues, params) => {
const replacedSegments = segments.map((val) => {
const withoutParamPrefix = (val as string).replace(/^:/, '');
const replacement =
withoutParamPrefix in segmentValues
? segmentValues[withoutParamPrefix as DynamicSegmentName]
: (val as string);
return replacement;
});
const paramQuery = queryStringify(params as any);
const pathWithoutParams = composePath(replacedSegments);
const paramSeparator = paramQuery ? '?' : '';
const path = `${pathWithoutParams}${paramSeparator}${paramQuery}`;
return path;
},
extend: (extendedSegments) =>
defineRoute([...segments, ...extendedSegments] as any[]),
useParams: () => useParams() as any,
} as RouteDefinition<DynamicSegmentName, ParamsDefinition>);
export default defineRoute;
import {Field as BaseField, FieldAttributes} from 'formik';
import {FC} from 'react';
// This will fix the broken typing of the `Field` props
const Field = BaseField as FC<FieldAttributes<Record<never,never>>>;
/**
* Formik (at time of writing) has broken typing on its
* `Field` components. The typing misues the `any` type
* in such a way that the `Field` component is set to `any`.
* The above code fixes that so you can get actual typing
* for the props and children of the `Field` component.
*
* NOTE: This fix is not perfect. The Formik typings (again,
* at time of writing) are super dumb, and whatever type you
* pass as the generic to `FieldAttributes` sets the type of
* that field's value. This could be easily worked around by
* just passing it `any` for simplicity, HOWEVER, the generic
* you pass to `FieldAttributes`, also gets applied to the
* prop type with `&`. This means that if we pass
* `FieldAttributes` the type `any` then we will lose all
* typing for the `Field` component.
*
* To work around this you will basically just have to cast
* `field.value` as `any` or whatever type you want whenever
* you use it.
**/
export default Field;
export * from "jotai";
export * from "jotai/utils";
export { atom as createAtom } from "jotai";
export {
atomFamily as createAtomFamily,
atomWithDefault as createAtomWithDefault,
atomWithHash as createAtomWithHash,
atomWithObservable as createAtomWithObservable,
atomWithReducer as createAtomWithReducer,
atomWithReset as createResettableAtom,
atomWithStorage as createAtomWithStorage,
selectAtom as createSelectorAtom,
splitAtom as createSplitAtom,
} from "jotai/utils";
import { atom, PrimitiveAtom, useAtom } from "jotai";
import { freezeAtom } from "jotai/utils";
import { useRef } from "react";
type ValueOrAtom<T> = T | PrimitiveAtom<T>;
const isAtom = <T>(v: ValueOrAtom<T>): v is PrimitiveAtom<T> => {
try {
freezeAtom(v as any);
return true;
} catch (e) {
return false;
}
};
/**
* Take either an atom or a value and return an atom.
*/
const useCoercedAtom = <T>(v: ValueOrAtom<T>): PrimitiveAtom<T> => {
const atomRef = useRef<PrimitiveAtom<T>>(isAtom(v) ? v : atom(v));
return atomRef.current;
};
/**
* "Invertible State" is state that can either be simple
* local state, or can be "inverted" to be global state.
* If this state is provided with a value, then that value
* will be used as the initial value of local state. If it
* is passed a state atom, then it will use that atom for
* state.
*
* The main use case is that you could have a component that
* uses some kind of local state, but you can choose to pass
* it a jotai atom, and then it will use that atom for its
* state, allowing you to access and manipulate the internal
* state of that component from outside.
*/
const useInvertibleState = <T>(v: ValueOrAtom<T>) => {
const theAtom = useCoercedAtom(v);
const stateControllers = useAtom(theAtom);
return stateControllers;
};
export default useInvertibleState;
import { useEffect, useState } from "react";
/**
* Get around the text-matching error caused by next.js client
* hydration.
*/
export const useHydrationTextWorkaround = <T>(
getActualValue: () => T,
fallbackValue: T,
): T => {
const [value, setValue] = useState<T>(fallbackValue);
useEffect(() => {
setValue(getActualValue());
}, [getActualValue]);
return value;
};
import dynamic from "next/dynamic";
import type { FC } from "react";
import { Suspense } from "react";
import type { Control, UseFormReturn } from "react-hook-form";
import { useFormContext } from "react-hook-form";
// if not using NextJS, replace the `dynamic` function with
// `lazy` from the `react` package, or whatever the framework
// you are using recommends for lazy loading components.
const DevTool = dynamic(
() =>
(process.env.NODE_ENV === "development"
? import("@hookform/devtools")
: new Promise<{ DevTool: FC<any> }>((resolve) => {
resolve({ DevTool: () => null });
})
).then((module) => module.DevTool),
{
ssr: false,
},
);
// Loads a mock version of the dev tool in production
// so the `@hookform/devtools` package is not included in the
// production bundle.
/**
* A wrapper around the `@hookform/devtools` component that provides
* the following benefits:
* - Pulls the `control` prop from the `FormContext` if it is not
* provided it as a prop.
* - Uses a mock version of the dev tool in production along with
* a conditional dynamic import to ensure that the `@hookform/devtools`
* package is not included in the production bundle.
*/
export const HookFormDevTool: FC<{ control?: Control }> = ({ control }) => {
const formContext = useFormContext() as UseFormReturn | undefined;
if (!formContext) {
throw new Error("HookFormDevTool must be used within a <FormProvider />");
}
const controlToUse = control || formContext?.control;
return (
<Suspense fallback={null}>
<DevTool control={controlToUse} />
</Suspense>
);
};
import { UseBoundStore } from "zustand";
export type Selector<Store extends UseBoundStore<any>, Derivation> = (
p: ReturnType<Store>
) => Derivation;
import { useModals } from "@mantine/modals";
import useOnMountEffect from "../hooks/useOnMountEffect";
import * as serviceWorkerRegistration from "../serviceWorkerRegistration";
import { Text } from "@mantine/core";
/**
* This hook will automatically update the installed
* PWA if an update is detected.
*
* To use this hook, remove the
* `serviceWorkerRegistration.register` call in
* `index.tsx` and instead call this hook inside
* `App.tsx`.
*
* You may also want to have the App do something like
* show a confirmation dialog to confirm if the app
* should reload rather than just having it
* automatically reload as soon as an update is
* detected.
*
* If you do want to stick with just automatically
* reloading, then you do not have to use this as
* a hook. Instead, simply copy the call to
* `serviceWorkerRegistration.register` this is inside
* the `useEffect` call in this hook, and move it into
* `index.tsx`. Make sure to also move the
* `reloadWithUpdates` function with it.
*/
const reloadWithUpdates = (workerRegistration: ServiceWorkerRegistration) => {
workerRegistration.waiting?.postMessage({ type: "SKIP_WAITING" });
window.location.reload();
};
const useServiceWorker = () => {
useEffect(() => {
serviceWorkerRegistration.register({
onUpdate: reloadWithUpdates,
});
}, []);
};
export default useServiceWorker;
import {useState, useCallback} from 'react';
export type CollectionStateController<T> = {
toggleItem: (item: T) => void;
includes: (item: T) => void;
reset: () => void;
clear: () => void;
set: (items: T[]) => void;
};
const basicComparison = (a: any, b: any) => a === b;
const useCollectionState = <T>(
initialState: T[] = [],
compare: (a: T, b: T) => boolean = basicComparison
): [T[], CollectionStateController<T>] => {
const [state, setState] = useState<T[]>(initialState);
const includes = useCallback(
(item: T) => state.some((selectedItem) => compare(item, selectedItem)),
[compare, state]
);
const toggleItem = useCallback(
(item: T) => {
const itemIsAlreadySelected = includes(item);
if (itemIsAlreadySelected) {
setState((currentState) =>
currentState.filter((selectedItem) => !compare(selectedItem, item))
);
} else {
setState((currentState) => [...currentState, item]);
}
},
[compare, includes]
);
const reset = useCallback(() => setState(initialState), [initialState]);
const clear = useCallback(() => setState([]), []);
return [
state,
{
toggleItem,
includes,
reset,
clear,
set: setState,
},
];
};
export default useCollectionState;
import { useRef } from "react";
/**
* returns a value that will never change after the
* first render. This does not use memoization so
* there is no performance cost to using it.
*/
const useConstant = <T>(value: T) => {
const valueRef = useRef(value);
return valueRef.current;
};
export default useConstant;
import { isEqual } from "lodash";
import { useEffect, useState } from "react";
/**
* Update state automatically when provided dependencies
* are updated.
*
* Example: If you have state that represents the id of an
* item in array that is selected, if that item is removed
* from the array, you can automatically update the state
* to not have the now non-existent item selected.
*/
const useConstrainedState = <Value, Deps extends unknown[]>(
initialState: Value,
deps: Deps,
getConstrainedValue: (value: Value, deps: Deps) => Value,
) => {
const [state, setState] = useState(initialState);
useEffect(() => {
const newState = getConstrainedValue(state, deps);
if (!isEqual(newState, state)) {
setState(newState);
}
}, [state, deps, getConstrainedValue]);
return [state, setState] as const;
};
export default useConstrainedState;
import { useEffect, useRef, useState } from "react";
type Timeout = ReturnType<typeof setTimeout>;
const safelyClearNullableTimeout = (timeoutInstance?: Timeout) => {
timeoutInstance && clearTimeout(timeoutInstance);
};
/**
* Returns a copy of the passed `value`, that is debounced,
* meaning that when the source `value` changes, the returned
* value will not change until the `delay` has passed without
* any additional updates to the source `value`.
*
* NOTE: If using react 18 or higher you should probably use
* the built-in `useDeferredValue` hook instead. In most use
* cases that hook will bring the same performance benefits
* without the delay of the debounce.
*/
const useDebouncedValue = <T>(value: T, delay: number): T => {
const timeoutRef = useRef<Timeout>();
const [debouncedState, setDebouncedState] = useState(value);
useEffect(() => {
safelyClearNullableTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setDebouncedState(value);
}, delay);
return () => {
safelyClearNullableTimeout(timeoutRef.current);
};
}, [delay, value]);
return debouncedState;
};
export const useDebouncedEffect = (
effect: () => void,
delay: number,
deps: any[],
) => {
const [runCounter, setRunCounter] = useState(0);
const debouncedRunCounter = useDebouncedValue(runCounter, delay);
useEffect(() => {
setRunCounter((v) => v + 1);
}, deps);
useEffect(() => {
effect();
}, [debouncedRunCounter]);
};
export default useDebouncedValue;
import { useDeferredValue, useEffect, useRef } from 'react';
const useDeferredEffect = <T>(effect: (p: T) => void, dep: T) => {
const deferredDep = useDeferredValue(dep);
useEffect(() => {
effect(deferredDep);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deferredDep]);
};
export const useDeferredUpdateEffect: typeof useDeferredEffect = (effect, dep) => {
const hasMountedRef = useRef(false);
useDeferredEffect((dep) => {
if (hasMountedRef.current) {
effect(dep);
} else {
hasMountedRef.current = true;
}
}, dep);
};
export default useDeferredEffect;
import { useEffect, useRef } from 'react';
const tryToCreateFileInput = () => {
// can't use document object in SSR so we return
// undefined if it throws an error
try {
return document.createElement('input');
} catch (e) {
return undefined;
}
};
export type FileUploadOptions = {
accept?: string;
};
/**
* Hook to handle file uploads.
* @param callback Callback that runs when a file is uploaded,
* receives the uploaded file as a parameter
* @param options Options for the file upload
* @param options.accept The type of file to accept, eg; 'image/*'
* will only accept image files.
* @return A function that will trigger a file upload
*/
export const useFileUpload = (
callback: (file: File) => void,
{ accept }: FileUploadOptions = {}
) => {
const fileInputRef = useRef(tryToCreateFileInput());
useEffect(() => {
if (fileInputRef.current) {
fileInputRef.current.type = 'file';
fileInputRef.current.style.display = 'none';
if (accept) {
fileInputRef.current.accept = accept;
}
fileInputRef.current.onchange = () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
callback(file);
};
document.body.appendChild(fileInputRef.current);
}
}, []);
const onUpload = () => fileInputRef.current?.click?.();
return onUpload;
};
/**
* Hook to handle image file uploads
* @param callback Callback that runs when a file is uploaded,
* receives generated src of the image file as the first parameter,
* and the full file object as the second parameter
* @return A function that will trigger a file upload
*/
export const useImageUpload = (
callback: (imageSrc: string, file: File) => void
) =>
useFileUpload((file) => callback(URL.createObjectURL(file), file), {
accept: 'image/*',
});
import { useDisclosure } from "@mantine/hooks";
import { useNavigate } from "@tanstack/react-router";
import { useCallback, useEffect } from "react";
// If not using mantine, you just need to replace it with
// `useState`, calls to `controller.open` and `controller.close`
// are just setting the open state to true or false
export const useHistoryDisclosure = () => {
const navigate = useNavigate();
const [value, controller] = useDisclosure();
const close = useCallback(() => {
if (value) {
history.back();
}
}, [value]);
const open = useCallback(() => {
controller.open();
navigate({
params: true,
search: true,
});
// Just need to navigate to the exact same page without changing
// any state, so that we have a sort "dummy" entry in the history
// so when the user goes back they will stay on the same page
}, [navigate, controller]);
useEffect(() => {
if (value) {
window.addEventListener("popstate", controller.close, { once: true });
}
return () => {
if (!value) {
window.removeEventListener("popstate", controller.close);
}
};
}, [value, controller]);
return [value, { open, close }] as const;
};
import { useCallback, useEffect, useMemo, useState } from 'react';
const basicArraysAreEqual = (a: unknown[], b: unknown[]) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
};
type ImgSrcFallbackCacheEntry = {
srcs: (string | undefined)[];
workingSrc: string;
};
const imgSrcFallbacksCache: ImgSrcFallbackCacheEntry[] = [];
const findFirstNonEmptySrcIndex = (srcs: (string | undefined)[]) => {
const indexOfNonEmptySrc = srcs.findIndex(Boolean);
if (indexOfNonEmptySrc === -1) return 0;
return indexOfNonEmptySrc;
};
/**
* If you have a series of possible `src` values to use for an image, and
* some of them are likely to error so you need to go through them to find
* the first one that works, this hook is for you.
*/
const useImgSrcFallbacks = (srcs: (string | undefined)[], deps: any[]) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableSrcs = useMemo(() => srcs, deps);
const [currentIndex, setCurrentIndex] = useState(
findFirstNonEmptySrcIndex(srcs)
);
const [hasError, setHasError] = useState(false);
useEffect(() => {
setCurrentIndex(findFirstNonEmptySrcIndex(stableSrcs));
}, [stableSrcs]);
useEffect(() => {
if (hasError) {
setHasError(false);
setCurrentIndex((prevIndex) => prevIndex + 1);
}
}, [hasError, currentIndex]);
const onError = useCallback(() => {
setHasError(true);
}, []);
const src = useMemo(() => {
const matchingCacheEntry = imgSrcFallbacksCache.find((cacheEntry) =>
basicArraysAreEqual(cacheEntry.srcs, stableSrcs)
);
if (matchingCacheEntry) return matchingCacheEntry.workingSrc;
return stableSrcs[currentIndex];
}, [currentIndex, stableSrcs]);
const onLoad = useCallback(() => {
const matchingCacheEntry = imgSrcFallbacksCache.find((cacheEntry) =>
basicArraysAreEqual(cacheEntry.srcs, stableSrcs)
);
if (!matchingCacheEntry) {
imgSrcFallbacksCache.push({
srcs: stableSrcs,
workingSrc: src!,
});
}
}, [src, stableSrcs]);
return { src, onError, onLoad };
};
export default useImgSrcFallbacks;
import { useState, useCallback, ChangeEvent } from "react";
const useInputState = (initialValue = "") => {
const [inputState, setInputState] = useState(initialValue);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setInputState(event.target.value);
},
[],
);
return [
inputState,
{ onChange, value: inputState },
{ setState: setInputState },
] as const;
};
export default useInputState;
import { useEffect, useRef } from 'react';
const useInterval = (callback: () => void, delay: number | null) => {
const savedCallbackRef = useRef(callback);
// Remember the latest callback if it changes.
useEffect(() => {
savedCallbackRef.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
// Don't schedule if no delay is specified.
// Note: 0 is a valid value for delay.
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallbackRef.current(), delay);
return () => clearInterval(id);
}, [delay]);
};
export default useInterval;
import * as React from "react";
const isInProduction = process.env.NODE_ENV === "production";
const useChangesMoreThanCutOffPerSecond = (
value: any,
cutoff: number,
callback: (changes: number) => void,
) => {
const changesInLastSecondRef = React.useRef(0);
React.useEffect(() => {
changesInLastSecondRef.current += 1;
}, [value]);
React.useEffect(() => {
const interval = setInterval(() => {
if (!isInProduction) {
console.debug(
"Changes in last second: ",
changesInLastSecondRef.current,
);
}
if (changesInLastSecondRef.current > cutoff) {
callback(changesInLastSecondRef.current);
}
changesInLastSecondRef.current = 0;
}, 1000);
return () => clearInterval(interval);
}, []);
};
/**
* Ensure a value is "stable", meaning it doesn't change too often. If
* the value is unstable (changes more than 10 times per second), an
* error will be thrown.
*/
const useMustBeStable = (value: any, valueName: string) => {
useChangesMoreThanCutOffPerSecond(value, 10, (changes) => {
if (!isInProduction)
throw new Error(
`The value "${valueName}" changed ${changes} times in the last second. This most likely means the value is unstable. "${valueName}" must be stable, which means it either must be wrapped in a 'useMemo', 'useCallback', 'useRef', or be initialised outside of a component.`,
);
});
};
export default useMustBeStable;
import { useState } from 'react';
type UseNumberStateOptions = {
max?: number;
min?: number;
};
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
const useNumberState = (
initialValue: number,
{ max = Number.POSITIVE_INFINITY, min = Number.MAX_VALUE * -1 }: UseNumberStateOptions = {}
) => {
const [value, setValue] = useState(initialValue);
const finalValue = clamp(value, min, max);
const increment = () => setValue(finalValue + 1);
const decrement = () => setValue(finalValue - 1);
const isAtMax = finalValue === max;
const isAtMin = finalValue === min;
return [finalValue, { increment, decrement, setValue, isAtMax, isAtMin }] as const;
};
export default useNumberState;
import { useCallback, useState } from "react";
const useObjectState = <T extends Record<string, any>>(initialState: T) => {
const [state, setState] = useState<T>(initialState);
const updateState = useCallback(
(stateUpdate: Partial<T> | ((p: T) => Partial<T>)) =>
setState((currentState) => ({
...currentState,
...(typeof stateUpdate === "function"
? stateUpdate(currentState)
: currentState),
})),
[],
);
return [state, updateState] as const;
};
export default useObjectState;
import { EffectCallback, useEffect } from 'react';
/**
* Run an effect when a component mounts and
* never re-run
*
* @param effect The effect to run
*/
const useOnMountEffect = (effect: EffectCallback) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, []);
};
export default useOnMountEffect;
const useOnUnmountEffect = (
effect: ReturnType<Parameters<typeof useEffect>[0]>
// eslint-disable-next-line react-hooks/exhaustive-deps
) => useEffect(() => effect, []);
import { useEffect, useRef } from "react";
const usePageTitle = (title: string, revertOnUnmount = true) => {
// Save what the title is before we change it
const previousTitleRef = useRef(document.title);
useEffect(() => {
document.title = title;
return () => {
if (revertOnUnmount) {
// Restore original title
document.title = previousTitleRef.current;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title]);
};
export default usePageTitle;
import { useRef, useEffect } from 'react';
/**
* Hook that returns the previous version of
* a provided value.
* NOTE: This hook is designed to be used to
* track the previous value of a state item.
* The value returned will only be guaranteed
* to be correct if the value changing will
* trigger a re-render. Eg; This hook may not
* correctly track the previous value of a ref
* because ref's do not trigger a re-render
* when changed.
*
* @param value The value to return the
* previous version of
* @returns The previous version of the value.
* If the version has not changed, returns
* undefined.
*/
const usePreviousValue = <T>(value: T): T | undefined => {
const ref = useRef<T>(undefined);
useEffect(() => {
// At first glance it may seem like this will cause the ref
// to have the current value instead of the previous value,
// but it will actually output the previous value because of
// the specific way in which React handles effects and how
// the value is returned from the hook.
// Basically the value gets returned from the hook *before*
// the value gets updated, which causes the returned value
// to always be 1 step behind the current value.
ref.current = value;
}, [value]);
// We can just return `ref.current` since the value gets
// updated in an effect, we know that whenever it gets
// updated a re-render must have just happened. Because of
// that, we don't need to use state to ensure we are
// returning the correct value.
return ref.current;
};
export default usePreviousValue;
import { useCallback, useState } from "react";
const last = <T>(array: T[]) => {
if (array.length === 0) {
return undefined;
}
return array[array.length - 1];
};
const basePush = <T>(array: T[], ...values: T[]): T[] => [...array, ...values];
function push<T>(...values: T[]): (array: T[]) => T[];
function push<T>(arr: T[], ...values: T[]): T[];
function push<T>(...values: T[]): T[] | ((arr: T[]) => T[]) {
if (values.length === 0) {
return (array: T[]) => basePush(array, ...values);
}
return basePush(values as T[]);
}
type ImmutablePopReturn<T> = {
array: T[];
} & (
| {
wasEmpty: false;
value: T;
}
| {
wasEmpty: true;
}
);
const pop = <T>(array: T[]): ImmutablePopReturn<T> => {
if (array.length === 0) {
return { array, wasEmpty: true };
}
const value = last(array) as T;
const rest = array.slice(0, array.length - 1);
return { array: rest, value, wasEmpty: false };
};
const UNDO = Symbol("UNDO");
const REDO = Symbol("REDO");
type Undo = typeof UNDO;
type Redo = typeof REDO;
type SpecialHistoryAction = Undo | Redo;
type StateHistoryTree<State> = {
current: State;
undoStack: State[];
redoStack: State[];
};
export const useReducerWithHistory = <State, Action>(
baseState: State,
reducer: (state: State, action: Action) => State,
) => {
const [stateTree, setStateTree] = useState<StateHistoryTree<State>>({
current: baseState,
undoStack: [],
redoStack: [],
});
const treeReducer = useCallback(
(
currentStateTree: StateHistoryTree<State>,
action: Action | SpecialHistoryAction,
): StateHistoryTree<State> => {
const { current, undoStack, redoStack } = currentStateTree;
if (action === UNDO) {
const undoPopResult = pop(undoStack);
if (undoPopResult.wasEmpty) {
return currentStateTree;
}
const { array: newUndoStack, value: previousState } = undoPopResult;
return {
current: previousState,
undoStack: newUndoStack,
redoStack: push(redoStack, current),
};
}
if (action === REDO) {
const redoPopResult = pop(redoStack);
if (redoPopResult.wasEmpty) {
return currentStateTree;
}
const { array: newRedoStack, value: nextState } = redoPopResult;
return {
current: nextState,
undoStack: push(undoStack, current),
redoStack: newRedoStack,
};
}
const newState = reducer(current, action);
return {
current: newState,
undoStack: push(undoStack, current),
redoStack: [],
};
},
[reducer],
);
const dispatch = useCallback(
(action: Action | SpecialHistoryAction) => {
const newStateTree = treeReducer(stateTree, action);
setStateTree(newStateTree);
},
[treeReducer, stateTree],
);
return [stateTree.current, dispatch] as const;
};
import { useRef, useEffect } from 'react';
// Returns a ref containing the current render count.
const useRenderCountRef = () => {
const renderCountRef = useRef(0); //We start at 0 because this will get incremented
// before any calls to `useEffect` which may reference this ref will run.
useEffect(() => {
renderCountRef.current += 1;
})
return renderCountRef;
}
export default useRenderCountRef;
import { useEffect, useRef } from "react";
const composeStyledConsoleLog = (stringStyleCombos: [string, string][]) => {
const strings = stringStyleCombos.map(([string]) => string);
const styles = stringStyleCombos.map(([, style]) => style);
return [strings.map((str) => `%c${str}`).join(""), ...styles];
};
const seededRandomColor = (seed: string) => {
const random =
(Math.abs(
Math.sin(
seed
.split("")
.map((char) => char.charCodeAt(0))
.reduce((acc, code) => acc + code, 0),
),
) *
16777215) >>
0;
// use of hsl allows us to make sure the colors are bright
// so they are readable in the console
return `hsl(${random % 360}, 100%, 50%)`;
};
const getLogPrinter =
(componentName: string, extraContext: (string | number)[] = []) =>
(extraMessage: string) => {
if (process.env.NODE_ENV !== "development") return;
const componentNameColor = seededRandomColor(componentName);
console.debug(
...composeStyledConsoleLog([
[`[${componentName}`, `color: ${componentNameColor}`],
...(extraContext.length > 0
? ([[" (", "color: white;"]] as [string, string][])
: []),
...extraContext.map(
(context, index) =>
[
`${context}${index !== extraContext.length - 1 ? "," : ""}`,
`color: ${seededRandomColor(String(context))};`,
] as [string, string],
),
...(extraContext.length > 0
? ([[")", "color: white;"]] as [string, string][])
: []),
["] ", `color: ${componentNameColor}`],
[extraMessage, "color: inherit;"],
]),
);
};
export const useRenderLogger = (
componentName: string,
extraContext: (string | number)[] = [],
enabled = true,
) => {
const renderCountRef = useRef(0);
const loggerRef = useRef(getLogPrinter(componentName, extraContext));
const enabledRef = useRef(enabled);
useEffect(() => {
renderCountRef.current += 1;
enabledRef.current = enabled;
if (enabled) {
loggerRef.current(
renderCountRef.current > 1
? `rendered ${renderCountRef.current} times`
: "mounted",
);
}
});
useEffect(() => {
const logger = loggerRef.current;
return () => {
if (enabledRef.current && renderCountRef.current > 1) {
logger("unmounted");
}
};
}, []);
};
type UseScrollControllerRefOptions = {
scrollDistance?: number;
};
// A hook to help you write the logic for horizontal scroll
// buttons in a carousel
export const useScrollControllerRef = ({
scrollDistance = 400,
}: UseScrollControllerRefOptions = {}) => {
const elementRef = useRef<HTMLElement | null>(null);
const [isScrolledToLeftEnd, setIsScrolledToLeftEnd] = useState(false);
const [isScrolledToRightEnd, setIsScrolledToRightEnd] = useState(false);
const updateScrollStates = () => {
setIsScrolledToLeftEnd((elementRef.current?.scrollLeft ?? 0) === 0);
setIsScrolledToRightEnd(
(elementRef.current?.scrollLeft ?? 0) +
(elementRef.current?.clientWidth ?? 0) >=
(elementRef.current?.scrollWidth ?? 0),
);
};
const onScrollLeft = () =>
elementRef.current?.scrollBy({
left: -scrollDistance,
behavior: "smooth",
});
const onScrollRight = () =>
elementRef.current?.scrollBy({ left: scrollDistance, behavior: "smooth" });
useEffect(() => {
updateScrollStates();
}, []);
return [
elementRef,
{
onScrollLeft,
onScrollRight,
onScroll: updateScrollStates,
isScrolledToLeftEnd,
isScrolledToRightEnd,
},
] as const;
};
import { SetStateAction, useMemo, useState } from 'react';
export type StatefulRef<Value> = {
(action: SetStateAction<Value>): void;
current: Value;
};
/**
* A "stateful ref" uses the same API as a standard ref, but
* will trigger a re-render when the "current" field is changed.
* This allows us to use a ref to pass to an HTML element, and
* use a `useEffect` call to update to any changes in that HTML
* element.
*/
const useStatefulRef = <Value>(
initialValue: Value | (() => Value)
): StatefulRef<Value> => {
const [value, setValue] = useState(initialValue);
const statefulRef: StatefulRef<Value> = useMemo(() => {
const baseStatefulRef = Object.assign(setValue, { current: value });
const statefulRefWithRenderTrigger = new Proxy(baseStatefulRef, {
set(_, field, newValue) {
if (field === 'current') {
setValue(newValue);
return true;
}
return false;
},
});
return statefulRefWithRenderTrigger;
}, [value]);
return statefulRef;
};
export default useStatefulRef;
import { SetStateAction, useCallback, useState } from "react";
type Middleware<T> = (p: T) => T;
const useStateWithMiddleware = <T>(
initialValue: T,
middleware: Middleware<T>,
) => {
const [state, setState] = useState(initialValue);
const setWithMiddleware = useCallback(
(update: SetStateAction<T>) =>
setState((prev) => {
const rawUpdate: T =
typeof update === "function" ? (update as any)(prev) : update;
const updateAfterMiddleware = middleware(rawUpdate);
return updateAfterMiddleware;
}),
[state, middleware],
);
return [state, setWithMiddleware] as const;
};
export default useStateWithMiddleware;
import { isEqual } from "lodash"; // If not using lodash, swap this out with another deep equality fn
import { useCallback, useEffect, useState } from "react";
const dataCanBeSafelyStored = (data: unknown) => {
const processed = JSON.parse(JSON.stringify(data));
return isEqual(processed, data);
// If you use superjson package you won't need to safety
// check the data at all
};
const useStorageState = <Value>(
storage: Storage,
key: string,
defaultValue: Value,
) => {
const [state, setState] = useState(() =>
JSON.parse(storage.getItem(key) ?? JSON.stringify(defaultValue)),
);
const checkDataSafety = useCallback((value: Value) => {
if (!dataCanBeSafelyStored(value)) {
throw new Error(`Value cannot be safely stored in storage (${value})`);
}
}, []);
useEffect(() => {
checkDataSafety(defaultValue);
}, [defaultValue, checkDataSafety]);
useEffect(() => {
checkDataSafety(state);
storage.setItem(key, JSON.stringify(state));
}, [state, storage, key, checkDataSafety]);
return [state, setState] as const;
};
export default useStorageState;
import { useRef, useEffect, useState } from "react";
const useThrottledValue = <T>(value: T, delay: number) => {
const latestValueRef = useRef(value);
const timeoutRef = useRef<NodeJS.Timeout>();
const [throttledState, setThrottledState] = useState(value);
useEffect(() => {
latestValueRef.current = value;
}, [value]);
useEffect(() => {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
setThrottledState(latestValueRef.current);
timeoutRef.current = undefined;
}, delay);
}
}, [value]);
return throttledState;
};
export default useThrottledValue;
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect, useMemo, useState } from 'react';
// NOTE: REPLACE THIS WITH A DEEP EQUALITY CHECKER
const defaultEqualityChecker = (a: any, b: any) => a === b;
/**
* Control react state that has a two-way
* binding to an external value.
*
* @param externalValue The value to bind with.
* Used as the initial state for the local state.
* When this changes, the local state is updated
* to match.
* @param setExternalValue A function that sets
* the external value
* @param [equalityChecker] A function for
* comparing the local state to the external
* value to determine if an update needs to
* occur. Defaults to a deep equality check,
* so the only reason to override this is if
* you can improve performance with a partial
* comparison.
* @returns standard [state, setState] tuple
* returned by `useState`.
*/
const useTwoWayBoundState = <T>(
externalValue: T,
setExternalValue: (p: T) => any,
equalityChecker: (a: T, b: T) => boolean = defaultEqualityChecker
) => {
const [localValue, setLocalValue] = useState(externalValue);
// Whether or not the values are equal
const dataIsTheSame = useMemo(
() => equalityChecker(localValue, externalValue),
[localValue, externalValue]
);
useEffect(() => {
if (!dataIsTheSame) {
// When external value is changed, update
// local value
setLocalValue(externalValue);
}
}, [externalValue]);
useEffect(() => {
if (!dataIsTheSame) {
// When local value changes, update external
// value
setExternalValue(localValue);
}
}, [localValue]);
return [localValue, setLocalValue] as const;
};
export default useTwoWayBoundState;
import { useEffect, useRef } from 'react';
const useUpdateEffect = (effect: () => void, deps?: any[]) => {
const hasMountedRef = useRef(false);
useEffect(() => {
if (hasMountedRef.current) {
effect();
} else {
hasMountedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
};
export default useUpdateEffect;
import React, { FC, ComponentProps } from "react";
const withDefaultProps =
<Props extends Record<string, any>, SelectedProps extends keyof Props>(
Component: FC<Props>,
defaultProps: Required<Pick<Props, SelectedProps>>,
): FC<Omit<Props, SelectedProps> & Partial<Pick<Props, SelectedProps>>> =>
// eslint-disable-next-line react/display-name
(props) =>
(
<Component
{...({ ...defaultProps, ...props } as any as ComponentProps<
typeof Component
>)}
/>
);
export default withDefaultProps;
import { FC, PropsWithChildren } from 'react';
const withWrapper =
<WrapperProps extends PropsWithChildren<unknown>, MainProps>(
Wrapper: FC<WrapperProps>,
getWrapperProps: (p: MainProps) => Omit<WrapperProps, 'children'>,
Main: FC<MainProps>
// eslint-disable-next-line react/display-name
): FC<MainProps> =>
(props) =>
(
<Wrapper {...(getWrapperProps(props) as WrapperProps)}>
<Main {...props} />
</Wrapper>
);
export default withWrapper;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment