Skip to content

Instantly share code, notes, and snippets.

@ackvf
Last active January 31, 2025 01:11
React utils

React Utils

other gists
🔗 TypeScript type toolbelt
🔗 TS/JS utility functions


  • useDependencies() - Tool to analyze which hook dependencies trigger an update. Can also be used with ordinary arrays or objects to keep track or visualize changes.

    const veryExpensiveHook =  useMemo(() => expensive(), [error, loading, mintPrice, protocolFee, ...])
    const analysis = useDependencyHook(() => expensive(), [error, loading, mintPrice, protocolFee, ...])
    console.log(analysis)

    console screenshot

  • useLessFormState() - Minimalistic, extensible, advanced & type-safe form-state management library with referentially stable handlers.
    with useless form

    const [formState, { onInputChange }] = useLessFormState<MyFormType>({ name: '', address: '' })
    return (
      <>
        <input name='name' value={formState.name} onChange={onInputChange} />
        <input name='address' value={formState.address} onChange={onInputChange} />
      </>
    )

    without useless form

    const [name, setName] = useState('')
    const [address, setAddress] = useState('')
    const handleNameChange = useCallback((event) => setName(event.target.value), [setName])
    const handleAddressChange = useCallback((event) => setAddress(event.target.value), [setAddress])
    return (
      <>
        <input value={name} onChange={handleNameChange} />
        <input value={address} onChange={handleAddressChange} />
      </>
    )
  • useLessFormErrors() - Minimalistic, extensible, advanced & type-safe validation library with referentially stable handlers. It is compatible with useLessFormState.

    const formState = { name: 'qwerty', confirmName: 'yolo' }
    const formErrors = getFormErrors(
      formState,
      {
        name: [
          // Manual rule definition consists of a validator function and a message.
          [v => v.length >= 5, 'Name is too short'],
          [v => v.includes('green'), 'Name is not green'],
        ],
        confirmName: [
          // There are plenty of pre-made rules
          [check.minLength(5), 'Name is too short'],
          // and even full reusable validation objects!
          validations.required,
          // Access the full state too.
          [(value, state) => state.name === value, 'Names are not same.'],
        ],
      }
    )
  • useQueue() - This state manager operates on a queue (array), implementing custom actions pop, push, shift, unshift, that trigger re-renders.

    const [queue, refQueue] = useQueue(['a', 'b', 'c'])
    queue.push('d')
    const first = queue.shift()
    function callback() { console.log(refQueue.current) } // always latest value
  • useCounter() - This is a simple counting state manager. Referentially stable.

    const [step, next, prev, goTo, reset, refState] = useCounter()  // initial state set to `0`
    const [counter, add, remove, , reset] = useCounter(2)  // initial state set to `2`
    
    add()    // +1
    remove() // -1
    reset()  // =2
  • useMultiwaySwitch() - An extension to useCounter. This counting state manager toggles between true and false and counts the number of switches turned on. It is useful for asynchronous and concurrent actions as it can be controlled from multiple places and only turns off when every switch is turned off, e.g. when every action finishes, etc.., causing a state change, which triggers a re-render.

  • useShallowState() - This state manager allows to store multiple keys similar to this.setState() from React Class Components. Any state updates are shallowly merged with the old state object, replacing any old properties and keeping those that did not change.

    const [state, setState] = useShallowState({ a: 1, b: 1 }/* initial state */)
    setState((oldState) => ({ b: oldSate.b + 1 }))
    setState({ c: 3 })
    // state is now { a: 1, b: 2, c: 3 }
  • useToggle() - Primitive boolean* state manager built with useReducer with referentially stable convenience handlers. (*also supports null)

    const [state, toggle, , , , refState] = useToggle(/* true | false | null */)
    
    const submitForm = useCallback(() => {
      if (refState.current) {/* ... */} // always latest value
    }, [refState]) // never changes reference
    
    // ... later in the component
    return <button onClick={toggle}>{state ? 'ON' : 'OFF'}</button>
  • useForceUpdate() - This void state manager allows to trigger a re-render of a component.

    const forceUpdate = useForceUpdate()
    // later
    forceUpdate() // or
    return <button onClick={forceUpdate}>Force update</button>
  • useOnFocusOutside() - This hook is used to detect mouse events and tab navigation outside of a given element.

    useOnFocusOutside(/* id */'Dropdown', () => closeDropdown())
  • getNodeText()
    react-getNodeText Sandbox screenshot

  • ComponentWithGenerics Type helper that allows to pass a generic type to a component and use it in the component's props.

    interface MyForm { name: string, address: string }
    <Input<MyForm> name='name' /> // OK
    <Input<MyForm> name='yolo' /> // Error - 'yolo' is not assignable to 'name' | 'address'
  • 🔗 Compose & Higher Order Components and Functions

  • 🔗 React TypeScript sessions

  • 🔗 React typings

// Jan 2023
/**
* Provides better intellisense for JSX Components at call-site by allowing them to accept additional generics.
*
* In some cases it also switches `onChange` handler from `(value: string) => void` to `(event: HTMLInputEvent<..>) => void`
* to be used with `useFormState()` hook's e.g. `onInputChange` which accepts `EventStub` (`(ev: { target: { name, value }}) => void`).
*
* @example
* interface FormState {a, b}
*
* // before
* <Input name='yolo' />
*
* // after
* <SliderInput<FormState>
* name='yolo' // 'yolo' is not assignable to 'a' | 'b'
* onChange={(value: string) => ...} // type (value: string) => void is not assignable to (ev: EventStub) => void
* />
*/
type FormInput<C extends React.FC<any>> = <FormState extends AnyObject = { ___: true }>(...params: FormState extends { ___: true } ? Parameters<C> : [Parameters<C>[0] & { name: keyof FormState }]) => ReturnType<C>
/**
* Provides better intellisense for JSX Components at call-site by allowing them to accept additional generics.
*
* @example
* interface FormState {name, address}
*
* // before
* const Input: React.FC<InputProps> ...
* <Input name='yolo' /> // this is wrong, but IDE won't tell you
*
* // after
* const Input: ComponentWithGenerics<React.FC<InputProps>> ...
* <Input<FormState> name='yolo' /> // 'yolo' is not assignable to 'name' | 'address'
*/
type ComponentWithGenerics<C extends React.FC<any>> = <FormState>(props: { name: keyof FormState } & Parameters<C>[0], context?: Parameters<C>[1]) => ReturnType<C>
// v2
/**
* @example
*
* // Here React.FC types the whole signature, both the Arguments and Return type.
* // We can't use generic type `T` in the React.FC props type before it is declared on the parenthesis as a generic.
* const fx: React.FC<{ hi: boolean; myProp: T }> = <T>({}) => <>hi</>
* // ^ ... Cannot find name 'T'.
*
* // Hence, we must split React.FC into two types, to remove it from variable declaration and put it later AFTER our generic type.
* ;type C = React.FC
* ;type Arguments = Parameters<C>
* ;type Returns = ReturnType<C>
*
* // We can then do this
* ;type NewArguments<MyGeneric> = Arguments & MyGeneric
* // or
* <MyGeneric>(props: Arguments & MyGeneric) => Returns
* // and with this, we can now consume the `MyGeneric` inside the arguments type, because the Generic is defined BEFORE the paren.
*/
type ApplyGenericType<C extends React.FC<any>> = <MyGeneric extends AnyObject>(
props: Parameters<C>[0] & MyGeneric
) => ReturnType<C>
/**
* This is enhanced version that does "some" type checking.
*
* Here `extends Partial<...>` basically does nothing, but removing `Partial` will make it so that the generic is *required* to provide overrides for the original props.
*/
type ApplyGenericTyped<C extends React.FC<any>> = <MyGeneric extends Partial<ExtractGenerics<C>> = ExtractGenerics<C>>(
props: Parameters<C>[0] & MyGeneric
) => ReturnType<C>
type AnyObject<T = any> = Record<string, T>
type AnyFunction = (...args: any[]) => any
// TEST ----------------------------------------------------------------------------------------------------------------
interface Props {
hi: string
}
interface Narrowed {
hi: 'MINTED' | 'BRIDGING'
}
type T01 = Parameters<React.FC<Props>>[0] & Narrowed
type T02 = ReturnType<React.FC<Props>>
const Gx: ApplyGenericType<React.FC<Props>> = ({}) => <>OK</>
const t01 = <Gx<Narrowed> hi='BRIDGING' /> // OK
const t02 = <Gx<Narrowed> hi='anything' /> // ERROR: Type '"anything"' is not assignable to type '"MINTED" | "BRIDGING"'.
const t03 = <Gx hi='anything' />
const t04 = <Gx<Narrowed> hi='' /> // <- 👈🔍 try invoking intellisense here - gives 'BRIDGING | MINTED'
const t05 = <Gx hi='' /> // <- 👈🔍 try invoking intellisense here - gives nothing
// ---
type ExtractGenerics<T> = T extends React.FC<infer U> ? U : never
declare const fy: React.FC<Props> // you can declare a variable without having to implement it
type T06 = ExtractGenerics<typeof fy>
type T07 = ExtractGenerics<React.FC<{something: boolean}>>
// ---
const Gy: ApplyGenericTyped<React.FC<Props>> = ({}) => <>OK</>
const t06 = <Gy hi='hi'/>

StackOverflow: React: Is there something similar to node.textContent?

react-getNodeText Sandbox screenshot

https://codesandbox.io/s/react-getnodetext-h21ss

const getNodeText = (node: React.ReactNode): string => {
  if (node == null) return ''

  switch (typeof node) {
    case 'string':
    case 'number':
      return node.toString()

    case 'boolean':
      return ''

    case 'object': {
      if (node instanceof Array)
        return node.map(getNodeText).join('')

      if ('props' in node)
        return getNodeText(node.props.children)
    } // eslint-ignore-line no-fallthrough

    default: 
      console.warn('Unresolved `node` of type:', typeof node, node)
      return ''
  }
}

simplified

const getNodeText = node => {
  if (['string', 'number'].includes(typeof node)) return node
  if (node instanceof Array) return node.map(getNodeText).join('')
  if (typeof node === 'object' && node) return getNodeText(node.props.children)
}
import { useCallback, useReducer, useRef } from 'react'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @description This is a simple counting state manager. See also {@link useMultiwaySwitch}.
*
* @example // 1. Initialize the state
*
* const [step, next, prev, goTo, reset, refState] = useCounter() // initial state set to `0`
* const [counter, add, , , reset] = useCounter(2) // initial state set to `2`
*
* @example // 2. Use the actions
*
* const [counter, add, remove, adjust, reset] = useCounter(2)
*
* add() // `+1`, alias for adjust(true)
* remove() // `-1`, alias for adjust(false)
*
* adjust(true) // `+1`
* adjust(false) // `-1`
*
* adjust(-2) // counter: -2 // adjust counter value to -2
* adjust(0) // counter: 0
*
* add() // counter: 1
* add() // counter: 2
* remove() // counter: 1
* remove() // counter: 0
* remove() // counter: -1
*
* reset() // counter: 2
*/
export default function useCounter(initialState: number): [
counter: number,
/** Adds 1 to the counter. */
add: () => void,
/** Subtracts 1 from the counter. */
remove: () => void,
adjust: {
/**
* Sets the value of the counter.
* @example
* adjust(2) // counter: 2
* adjust(0) // counter: 0
*/
(turnTo: number): void
/**
* Adjusts the counter by one. Same as `add()` or `remove()`.
* @example
* adjust(true) // `+1`
* adjust(false) // `-1`
*/
(addOrRemove: boolean): void
},
/** Sets the value of the counter to initial value. Alias to `adjust(initialState)` */
reset: () => void,
/** Escape hatch to make life easier inside hooks and callbacks ¯\_(ツ)_/¯ */
refState: { current: number }
] {
const [counter, change]: [number, (turn: number | boolean) => void] = useReducer(
(state: number, turn: number | boolean) => Math.max(typeof turn === 'boolean' ? state + (turn ? 1 : -1) : turn, 0),
Number(initialState)
)
const add = useCallback(() => change(true), [])
const remove = useCallback(() => change(false), [])
const adjust = useCallback((to: number | boolean) => change(to), [])
const reset = useCallback(() => change(initialState), [])
const refState = useRef<number>(counter as number)
refState.current = counter as number
return [counter as number, add, remove, adjust, reset, refState]
}
// related: https://stackoverflow.com/questions/77108996/changing-the-trace-and-file-and-line-number-in-devtools-console-log
import type { MutableRefObject } from 'react'
import { useRef } from 'react'
interface DependencyAnalysis<T extends any[]> {
/** At least one dependency changed from last render, which would cause a hook to be re-run. */
isDirty: boolean
/** Names of dependencies, if provided. Useful for pretty console.log output. */
names?: Partial<ReTuple<T, string>>
/** These are the new values of changed dependencies from last render. */
changed: OptionalTuple<T>
/** Previous dependencies array. */
prev: T
/** Current dependencies array. */
deps: T
/** Initial dependencies array. */
initial: T
/** Dependency differs from last render. */
isChanged: boolean[]
/** Dependency is pristine and has not been changed at all (from initial state). */
isPristine: boolean[]
}
interface Config {
disableLog?: boolean
name?: string
}
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @description
* This hook accepts another hook's dependency array and returns data for analysis.
* It can also print comprehensive analytical data to console automatically, without having to process them by hand.
*
* There are four variants, the latter two are shorthands to the first, with limited functionality.
* - `useChangedDependencies(dependencies, names?, config?)`
* - `useChangedDependencies(dependencyObject, config?)` // dependencies as object automatically provide names
* - `useDependencyHook([...,] dependencies)` // see advantages below
* - `useDependencySilentHook([...,] dependencies)` // doesn't do console.log
*
* `useChangedDependencies`
* Prints comprehensive logs to console using the provided named array
* and provides `dependencyAnalysis` as return object for further processing *(optional)*.
* @example
* useChangedDependencies({chainId, address}, { name: 'Mint:useEffect' })
* useChangedDependencies([chainId, address], ['chainId', 'address'], options)
* const dependencyAnalysis = useChangedDependencies([chainId, address])
* console.log(dependencyAnalysis.changed)
*
* @description
* The advantage of **`useDependencyHook`** or **`useDependencySilentHook`** is to be used directly with original code.
* It extracts only the last parameter, which is the dependency array.
*
* @example
* // before (original hook)
* const memoized = useMemo(() => {...}, [chainId, address])
* // after
* const memoized_ = useDependencyHook(() => {...}, [chainId, address])
*
* @example
* // These are equivalent
* useDependencyHook(...args, [chainId, address])
* useChangedDependencies([chainId, address])
*
* // These are equivalent
* const { isDirty } = useDependencySilentHook([chainId, address])
* const { changed } = useChangedDependencies([chainId, address], undefined, { disableLog: true })
* console.log('changed:', isDirty, changed)
*/
export function useChangedDependencies<T extends any[]>(dependencies: [...T], names?: Partial<ReTuple<T, string>>, config?: Config): DependencyAnalysis<T> & { refs: MutableRefObject<DependencyAnalysis<T>> }
export function useChangedDependencies<O extends AnyObject, T extends Array<keyof O> = Array<keyof O>>(dependencyObject: O, config?: Config): DependencyAnalysis<T> & { refs: MutableRefObject<DependencyAnalysis<T>> }
export function useChangedDependencies<T extends any[]>(...args: [...T]) {
let dependencies: T
let names: ReTuple<T, string>
let config: Config
if (Array.isArray(args[0])) {
[dependencies, names, config = {}] = args as any
} else {
dependencies = Object.values(args[0]) as T
names = Object.keys(args[0]) as ReTuple<T, string>
config = args[1] as Config || {}
}
const { disableLog = false, name } = config
const printName = name ? ` (${name})` : ''
if (!disableLog) console.log(Y + '👮‍♂️ Rendered' + printName)
const initialDepsRef = useRef<T>(dependencies)
const prevDepsRef = useRef<T>(dependencies)
const LMax = Math.max(
dependencies.length,
initialDepsRef.current.length,
prevDepsRef.current.length
)
/** These dependencies are pristine and have not been changed at all. */
const pristine = useRef(generateArray<boolean[]>(LMax, () => true))
/** These dependencies differ from last render. */
const changed = useRef(generateArray<boolean[]>(LMax, () => false))
/** These are the changed dependencies from last render. */
const changedDeps = [] as unknown as OptionalTuple<T>
/** At least one dependency changed, which causes a hook to re-run. */
let dirty = false
for (let ix = 0; ix < LMax; ix++) {
const init = initialDepsRef.current[ix]
const prev = prevDepsRef.current[ix]
const depc = dependencies[ix]
pristine.current[ix] &&= init === depc
changed.current[ix] = prev !== depc
changedDeps[ix] = prev !== depc ? depc : undefined
dirty ||= prev !== depc
}
const rVal: DependencyAnalysis<T> = {
isDirty: dirty,
names,
changed: [...changedDeps],
prev: [...prevDepsRef.current],
deps: [...dependencies],
initial: [...initialDepsRef.current],
isChanged: [...changed.current],
isPristine: [...pristine.current],
} as unknown as DependencyAnalysis<T>
if (!disableLog && dirty)
console.log(
`👮‍♂️ ${R}Dependencies changed!${printName} ${X}%s\n%O🔍\n`,
dependencies
?.map(
(depc, ix) => '\n' + (
changed.current[ix]
? `${R}✗${Y} ${names?.[ix] || ''}${X} ${prettyPrint(prevDepsRef.current[ix])} ${R}⇢${X} ${prettyPrint(depc)}`
: `${G}✓${Y} ${names?.[ix] || ''}${X}`
)
)
.join('')
?? '',
rVal,
)
prevDepsRef.current = dependencies
return rVal
}
export default useChangedDependencies
export const useDependencyHook /* */ = (...args: [...any[], DependencyArray]) => useChangedDependencies(args.pop())
export const useDependencySilentHook = (...args: [...any[], DependencyArray]) => useChangedDependencies(args.pop(), undefined, { disableLog: true })
type DependencyArray = any[]
/* Helpers */
const X = '\x1b[0m'
const R = '\x1b[31m'
const G = '\x1b[32m'
const B = '\x1b[34m'
const C = '\x1b[36m'
const M = '\x1b[35m'
const Y = '\x1b[33m'
function prettyPrint(anything: any): string {
if (Object === (anything as Object)?.constructor) return printObject(anything)
if (Array.isArray(anything)) return printArray(anything)
return String(anything)
}
function printObject(obj: AnyObject): string {
const keys = Object.keys(obj)
const showKeys = 7
return `{ ${keys.slice(0, showKeys).join(', ')}${keys.length > showKeys ? ', ... ' : keys.length > 0 ? ' ' : ''}}`
}
function printArray(array: any[]): string {
return `[ Array(${array.length}) ]`
}
const generateArray = <T extends any[]>(length: number, mapFn: (ix: number) => any, thisArg?: any): T => {
const boundMapFn = mapFn.bind(thisArg)
return Array.from({ length }, (_, k) => boundMapFn(k)) as T
}
type AnyObject<T = any> = Record<string, T>
type ReTuple<T extends any[], NewType = any> = { [K in keyof T]: NewType }
type OptionalTuple<T extends any[]> = { [K in keyof T]: T[K] | undefined }
import { useReducer } from 'react'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @returns `forceUpdate` function
*
* @description This void state manager allows to trigger a re-render of a component.
*
* @example
*
* const forceUpdate = useForceUpdate()
*
* // later
* forceUpdate()
*/
export default function useForceUpdate(): () => void {
return useReducer(() => ({}), {})[1]
}
import { useMemo, useRef } from 'react'
import emojiRegex from 'emoji-regex'
import type { EventStub, useLessFormState } from './useLessFormState'
import useShallowState from './useShallowState'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*/
// Regular expression validators and sets ------------------------------------------------------------------------------
const r = <R extends string>(regex: R): TypedRegExp<R> => new RegExp(regex)
const c = <C extends string>(chars: C): TypedRegExp<C> => new RegExp(`^[${chars}]*$`)
/* Note: Special characters must be double escaped! https://262.ecma-international.org/5.1/#sec-7.8.4 */
/* Character sets */
const TEXT = `a-zA-Z`
const ALNUM = `a-zA-Z0-9`
const NUMBER = `0-9`
const SPECIAL_CHARACTERS = `_.,:;~+-=*'"^°\`<>(){}[\\\]!?$@&#%|\\\\/`
const SPECIAL_TEXTAREA_CHARACTERS = `${SPECIAL_CHARACTERS}\n\t•◦‣∙` as const
/* Regexes */
const DECIMAL_TEMPLATE = (decimals?: number) => `^\\d*(\\.\\d${(decimals === undefined) ? '*' as const : `{0,${decimals}}` as const})?$` as const
const ADDRESS = '^0x[a-fA-F0-9]{40}$'
const UUID = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
const URL = `^https?://[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}(:[0-9]{1,5})?(/.*)?$`
const EMOJI = emojiRegex()//.toString().slice(1,-2)
// Validation rules & messages -----------------------------------------------------------------------------------------
/**
* **Validator** functions - Each returns **true**: valid, **false**: invalid
* - *Note*: Some of the functions might be **curried**, i.e. you need to call twice.
*
* Validator is executed inside `getFormErrors()` with two values: `validator(inputValue, formState)`
*/
export const check = {
/** Makes a strict validator optional. */
optional: <V extends string | number, FormState extends AnyObject, TypeHint>(validator: Validator<V, FormState, TypeHint>): Validator<V, FormState, TypeHint> => ((v, s) => minLength(1)(v, s) ? validator(v, s) : true),
/** Compare that two fields are equal. */
matchesField: <FormState>(fieldName: keyof FormState) => (value: string, formState: FormState): boolean => value === formState[fieldName],
/* booleans */
isTrue: (value => value == true) as Validator<boolean>,
/* numbers * (note: all inputs are strings!) */
min: (min: number) => (value: Numberish) => Number(value) >= min,
max: (max: number) => (value: Numberish) => Number(value) <= max,
clamp: (min: number, max: number) => (value: Numberish) => Number(value) >= min && Number(value) <= max,
/* strings * (btw: all inputs are strings, so these can be used with all inputs) */
maxLength: (length: number): Validator<string | number> => s => s?.toString().length <= length,
minLength: minLength = (length: number): Validator<string | number> => s => s?.toString().length >= length,
notEmpty: minLength(1),
isTrimmed: (s: string) => s.trim() === s,
/* regex */
/** Matches a provided regex. */
isMatching: isMatching = r => s => r.test(s),
isText: isMatching(c(TEXT)),
isAlphaNumeric: isMatching(c(ALNUM)),
isNumeric: isMatching(c(NUMBER)),
isDecimal: (maxDecimals?: number) => isMatching(r(DECIMAL_TEMPLATE(maxDecimals))),
isURL: isMatching(r(URL)),
isAddress: isMatching(r(ADDRESS)),
isUUID: isMatching(r(UUID)),
/** Same as `isAlphaNumeric` but additionally allows spaces.*/
isAlphaNumericText: isMatching(c(ALNUM)),
/** AlphaNumeric text containing spaces and special characters. */
isRelaxedText: isMatching(c(`${ALNUM} ${SPECIAL_CHARACTERS}`)),
isRelaxedEmojiText: (s: string, state: any) => isMatching(c(`${ALNUM} ${SPECIAL_CHARACTERS}`))(s.replace(EMOJI, ''), state),
} as const
/**
* Common validation pair tuples - `[Validator, message]`
*/
export const validations = {
/** Makes a strict validation pair tuple optional. */
optional: <V extends string | number, FormState extends AnyObject, TypeHint>([validator, message]: ValidatorMessagePair<V, FormState, TypeHint>): ValidatorMessagePair<V, FormState, TypeHint> => [(value, formState) => check.optional(validator)(value, formState), message],
/** Check minimum length == 1. */ // TODO: Improve this rule for other data types? (Are there other data types for inputs anyway?)
required: [check.notEmpty, 'Required.'],
checked: [check.isTrue, 'Required.'],
onlyText: [check.isText, 'Only english letters are allowed.'],
onlyAlphaNumeric: [check.isAlphaNumeric, 'Only alphanumeric characters allowed.'],
onlyNumeric: [check.isNumeric, 'Only numbers allowed.'],
onlyDecimal: (decimals?: number) => [check.isDecimal(decimals), `Only numbers with ${decimals === undefined ? '' : `${decimals} ` as const}decimal places allowed.`] as const,
onlyAddress: [check.isAddress, 'Must be a wallet address.'],
onlyURL: [check.isURL, 'Must be a valid http / https url.'],
/** Same as `onlyAlphaNumeric` but additionally allows spaces.*/
onlyAlphaNumericText: [check.isAlphaNumericText, 'Only alphanumeric characters allowed.'],
/** Text containing spaces, special characters and emoji. */
relaxedEmojiText: [check.isRelaxedEmojiText, `Only alphanumeric characters with space, special characters [${SPECIAL_CHARACTERS}] and emoji allowed.`],
minLength: <T extends number>(minLength: T) => [check.minLength(minLength), `Too few characters, you need at least ${minLength} characters.`] as const,
maxLength: <T extends number>(maxLength: T) => [check.maxLength(maxLength), `Too many characters, limit to ${maxLength} characters.`] as const,
noWrappingWhitespace: [check.isTrimmed, 'Remove leading and trailing whitespace characters.'],
matchesField: <FormState extends AnyObject>(fieldName: keyof FormState, message?: string): ValidatorMessagePair<any, FormState> => [check.matchesField(fieldName), message ?? `Value doesn't match '${fieldName as string}' field`],
} as const
// "reference before declaration" hack, can't use `check.isMatching()` within another check.
var isMatching: <Regex extends TypedRegExp, TypeHint = Regex extends TypedRegExp<infer S> ? S : string>(regex: Regex) => Validator<string, any, TypeHint>
var minLength: (length: number) => Validator<string | number>
// ---------------------------------------------------------------------------------------------------------------------
// Hook implementation -------------------------------------------------------------------------------------------------
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* **Note:** You must provide `name` property on inputs.
*
* @params `(rules)`
* @returns `[formErrors, errorChecks, utilityFunctions]`
*
* @param initialRules Object with validation rules matching `FormState` keys.
*
* @see {@link useLessFormState} for more advanced use case.
*
* @example
* // This is a simplified example without integration with `useLessFormState()` hook.
* // See 👉`useLessFormState.ts` for more advanced use case.
*
* // It's really intuitive and easy to use:
*
* const [, {getFormErrors}] = useLessFormErrors<FormState>()
*
* const formErrors = getFormErrors(
* // formState //
* {
* name: 'yolo',
* confirmName: 'nope',
* },
* // validation rules to apply to each field name //
* {
* name: [
* // Manual rule definition consists of a validator function and a message.
* [v => v.length >= 5, 'Name is too short'],
* [v => v.includes('green'), 'Name is not green'],
* ],
* confirmName: [
* // There are plenty of pre-made rules
* [check.minLength(5), 'Name is too short'],
* // and even full reusable validation objects!
* validations.required,
*
* // Make a strict rule optional.
* validations.optional(validations.minLength(5)),
*
* // Access the full formState too. (These are all equivalent.)
* [(value, formState) => formState.name === value, 'Names are not same.'],
* [check.matchesField('name'), 'Names are not same.'], // Using pre-made Validator and a custom message.
* validations.matchesField('name'), // Using pre-made Validation that composes Validator and optional message.
* ],
* }
* )
*/
export function useLessFormErrors<FormState extends AnyObject>(
initialRules: RuleSet<FormState> = {}
): [
formErrors: FormErrors<FormState>,
errorChecks: {
/* *checks* operate on internal formErrors state and mutate it - eventually triggering input errors. */
/** Performs check for errors on **currently changed field**. To be used with `useLessFormState` `onChange` callback. E.g. `useLessFormState(...,{ onChange: checkFieldErrorsOnFormStateChange })`. */
checkFieldErrorsOnFormStateChange: (state: Partial<FormState>, changedField: keyof FormState) => void
/** Performs check for errors on **one field**. Can be used directly on input callbacks such as `onBlur`, `onChange`, etc. */
checkFieldErrors: (event: EventStub) => void,
/** Performs check for errors on **all or specified form fields**. Accepts adhoc rules definition. */
checkFormErrors: (formState: Partial<FormState>, changedFields?: Array<keyof FormState>, replaceRules?: RuleSet<Partial<FormState>>) => void,
/* *gets* return the result to the caller without storing it - meaning nothing will get passed to inputs as errors. */
/** Same as `checkFormErrors()` but doesn't save result in state. */
getFormErrors: (formState: Partial<FormState>, replaceRules?: RuleSet<Partial<FormState>>) => FormErrors<Partial<FormState>>,
/** Gets errors using `getFormErrors(formState)` and counts them using `countErrors(errors)`. Does not mutate state. */
getFormErrorsCount: (formState: Partial<FormState>, replaceRules?: RuleSet<Partial<FormState>>) => number,
},
utilityFunctions: {
setFormErrors: typeof setFormErrors,
clearFormErrors: () => void,
clearFieldErrors: (field: keyof FormState) => void,
/** Get first error from input field that has multiple validation errors. */
getFirstError: (errors?: string | string[]) => string | undefined,
/** Get all input validation errors joined into a single string. */
getAllErrors: (errors?: string | string[]) => string | undefined,
/** Counts **existing** errors in a provided `FormErrors` object, unlike `getFormErrorsCount(formState)` which checks the whole formState and then counts the found errors. */
countErrors: typeof utilityFunctions.countErrors,
// TODO `replaceRules(rules: RuleSet<FormState>)` to replace rules definition on runtime.
},
refErrors: { current: FormErrors<FormState> }
] {
const [formErrors, setFormErrors, { clearState: clearFormErrors, clearProperty: clearFieldErrors }] = useShallowState<FormErrors<FormState>>({})
const errorChecks = useMemo(() => ({
checkFieldErrorsOnFormStateChange: (formState: Partial<FormState>, name: keyof FormState) =>
setFormErrors(getFormErrors(formState, initialRules as AnyObject, [name])),
checkFieldErrors: ({ target: { name, value } }: EventStub) =>
setFormErrors(getFormErrors({ [name]: value }, initialRules as AnyObject, [name])), // TODO find better type?
checkFormErrors: (formState: Partial<FormState>, changedFields?: Array<keyof FormState>, rules: RuleSet<FormState> = initialRules) =>
setFormErrors(getFormErrors(formState, rules as AnyObject, changedFields)),
getFormErrors: (formState: Partial<FormState>, rules = initialRules) =>
getFormErrors(formState, rules as AnyObject),
getFormErrorsCount: (formState: Partial<FormState>, rules = initialRules) =>
utilityFunctions.countErrors(getFormErrors(formState, rules as AnyObject)),
}), []) // eslint-disable-line react-hooks/exhaustive-deps
const utilityFunctions = useMemo(() => ({
setFormErrors,
clearFormErrors,
clearFieldErrors,
getFirstError: (errors?: string | string[]) => Array.isArray(errors) ? errors[0] : errors,
getAllErrors: (errors?: string | string[]) => Array.isArray(errors) ? errors.join(', ').replace('., ', ', ') : errors,
countErrors: (formErrors: FormErrors<FormState> = refErrors.current) => Object.values(formErrors).flat().filter(ndef => ndef).length,
}), []) // eslint-disable-line react-hooks/exhaustive-deps
const refErrors = useRef(formErrors)
refErrors.current = formErrors
return [formErrors, errorChecks, utilityFunctions, refErrors]
}
// Validation core -----------------------------------------------------------------------------------------------------
function getFormErrors<FormState extends AnyObject, ChangedFields extends Array<keyof FormState>>(
formState: FormState,
rules: RuleSet<FormState>,
changedFields: ChangedFields = Object.keys(rules) as ChangedFields
): FormErrors<Pick<FormState, typeof changedFields[number]>> {
const errors: FormErrors<FormState> = {}
changedFields.forEach((field) => {
const value = formState[field]
const validatorMessagePairs: Array<ValidatorMessagePair<FormState[keyof FormState], FormState>> | undefined = rules[field]
if (validatorMessagePairs === undefined) return
const fieldErrors = validatorMessagePairs
.map(([validator, message]) => validator(value, formState) || message || 'Invalid value.')
.filter(valid => valid !== true) as string[]
errors[field] = fieldErrors.length ? fieldErrors : undefined
})
return errors
}
// Types ---------------------------------------------------------------------------------------------------------------
/** Returns **true** for valid, **false** for invalid */
type Validator<Value = any, State extends AnyObject = AnyObject, TypeHint = Value> = (value: Value, formState: State) => boolean
type ValidatorMessagePair<Value = any, State extends AnyObject = AnyObject, TypeHint = Value> = [validator: Validator<Value, State, TypeHint>, message: string] | readonly [validator: Validator<Value, State, TypeHint>, message: string]
type RuleSet<FormState extends AnyObject/*= AnyObject*/> = { [key in keyof FormState]?: Array<ValidatorMessagePair<FormState[key], FormState>> }
type FormErrors<FormState extends AnyObject = AnyObject> = { [key in keyof FormState]?: string | string[] }
interface TypedRegExp<TypeHint = ''> extends RegExp { }
type AnyObject<T = any> = Record<string, T>
type Numberish = number | `${number}`
import { useEffect, useMemo, useReducer, useRef } from 'react'
import type { ChangeEventHandler, Reducer } from 'react'
/**
* # Use less form state
*
* This small state management hook for inputs comes with a set of handlers and utility functions to significantly simplify management of form state.
*
* Every handler is tailored to a specific input type, achieving a high level of type safety, code readability,
* and most importantly, thanks to utilizing the `name` property also **referential stability**.
*
*
* @author Qwerty <qwerty@qwerty.xyz>
*
* **Note:** You must provide `name` property on inputs.
*
* @params `(emptyState, initialState = emptyState, callbacks?)`
* @returns `[state, onChangeHandlers, utilityFunctions, refState]`
*
* @param emptyState The state that is used when the form is cleared with `clearForm`.
* @param initialState aka *default values* - used to pre-populate input values on mount and when form is reset with `resetForm`.
* @param callbacks Function callbacks that should be called on internal state changes and other events.
*
*
* @example
* const [formState, { onInputChange }, , refState] = useLessFormState<NFTFormState>(EMPTY_STATE, initialState)
*
* const handleSubmit = useCallback(() => {
* console.log(formState, refState.current) // formState is old state, refState.current is ALWAYS latest state.
* }, []) // <- refState allows to avoid unnecessary callback updates
*
* @example // with more props and Form Validation
* const [formErrors, { checkFieldErrorsOnFormStateChange }] = useLessFormErrors<CollectionFormState>(validationRules)
*
* const [formState, { onInputChange, onDropDownChange, onToggle }, { resetForm }] = useLessFormState<CollectionFormState>(
* emptyCollectionForm,
* initialFormState,
* { onChange: checkFieldErrorsOnFormStateChange }
* )
*/
export function useLessFormState<S extends AnyObject = AnyObject>(
emptyState: S = {} as S, initialState: Partial<S> = emptyState, callbacks?: Callbacks<S>
): [
formState: S,
onChangeHandlers: {
/** Universal handler for any input. `name` property must be specified on the input. */
onInputChange: ChangeEventHandler<HTMLInputElement>
/** Universal handler for any `type=number` input. Value is converted to number. `name` property must be specified on the input. */
onNumberInputChange: ChangeEventHandler<HTMLInputElement>
/** Universal handler for any input. `name` property must be specified in the payload. */
onValueChange: (eventTarget: TargetStub<S>) => void
/** Handler tailored for `Dropdown` components. `name` property must be specified on the input. */
onDropDownChange: (option: OptionWithName) => void
/** Toggle handler that accepts an input Event. `name` property must be specified on the input. */
onToggle: ChangeEventHandler<HTMLInputElement>
/** Toggle handler that accepts Event.target stub. `name` property must be specified on the input. */
onToggleValue: (eventTarget: Pick<TargetStub<S>, 'name'>) => void
/** Universal handler that accepts Partial state objects. */
onObjectChange: (partialState: Partial<S>) => void
},
utilityFunctions: {
setField: {
/** Sets one field with `Event.target`-like object.
* @example setField({name: 'url', value: 'https://qwerty.xyz/'}) */
(eventTarget: TargetStub<S>): void
/** Sets multiple fields with access to `prevState`.
* @example setField(() => ({creator: '', url: ''}) setField(() => initialState) setField(({count}) => ({count: count + 1})) */
(updater: Updater<S>): void
},
/** Clears field value by setting it to its empty value. */
clearField: (field: keyof S) => void,
/** Resets field value to the `initialState` that was loaded from global Store or its `emptyState` value. */
resetField: (field: keyof S) => void,
/** Sets all fields to their empty values. */
clearForm: () => void,
/** Sets all fields to the `initialState` that was loaded from global Store or to their `emptyState` values. */
resetForm: () => void,
},
refState: { current: S }
] {
const [state, setField] = useReducer<Reducer<S, TargetStub<S> | Updater<S>>>(
(prevState, action) => {
const pending = typeof action === 'function' ? action(prevState) : { [action.name]: action.value }
const newState = { ...prevState, ...pending }
if (callbacks?.onChange) {
const changedFields = Object.keys(pending)
changedFields.forEach(field => callbacks?.onChange?.(newState, field))
}
return newState
},
{ ...emptyState, ...initialState }
)
const onChangeHandlers = useMemo(() => ({
onInputChange({ target }: EventStub<S>) { setField(target) },
onNumberInputChange({ target: { name, value } }: EventStub<S>) { setField({ name, value: Number(value) as any }) },
onValueChange(target: TargetStub<S>) { setField(target) },
onDropDownChange({ name, value: { value } }: OptionWithName<S[keyof S]>) { setField({ name, value }) },
onToggle({ target: { name } }: EventStub) { setField(prevState => ({ [name]: !prevState[name] } as S)) },
onToggleValue({ name }: TargetStub) { setField(prevState => ({ [name]: !prevState[name] } as S)) },
onObjectChange(partialState: Partial<S>) { setField(() => partialState) },
}), [])
const utilityFunctions = useMemo(() => ({
setField,
// TODO: clearing/resetting form triggers "onChange" callback and potentially also error validation, which then gives errors for empty fields.
// maybe add callback `onReset` to be used with useLessFormErrors.clearFormErrors()? OR add setField(partialFormState, {skipOnChange: true})?
clearField(name: keyof S) { setField({ name, value: emptyState[name] }) },
resetField(name: keyof S) { setField({ name, value: initialState[name] ?? emptyState[name] }) },
clearForm() { setField(prevState => Object.fromEntries(Object.keys(prevState).map(name => [name, emptyState[name]])) as S) },
resetForm() { setField(prevState => Object.fromEntries(Object.keys(prevState).map(name => [name, initialState[name]])) as S) },
}), []) // eslint-disable-line react-hooks/exhaustive-deps
const refState = useRef(state)
refState.current = state
useEffect(() => {
callbacks?.onMount?.(refState.current)
return () => callbacks?.onUnmount?.(refState.current)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// @ts-ignore Type 'ChangeEvent<HTMLInputElement>' is not assignable to type 'EventStub<S>'. The types of 'target.value' 'string' is not assignable to type 'S[keyof S]'.
return [state, onChangeHandlers, utilityFunctions, refState]
}
// Types ---------------------------------------------------------------------------------------------------------------
interface Callbacks<S> {
onChange?: (state: S, field: keyof S) => void
onUnmount?: (state: S) => void
// TODO instead of having to check errors on mount, the formState or formErrors should probably return something like "pristine/touched" prop.
onMount?: (state: S) => void
}
type Updater<S = AnyObject> = (prevState: S) => Partial<S>
/** HTMLInput Event stub */
export interface EventStub<S = AnyObject> {
target: TargetStub<S>
}
/** HTMLInput Event.target stub */
export interface TargetStub<S = AnyObject> {
name: keyof S
value: S[keyof S]
}
/** Dropdown Option */
export interface Option<T extends string = string> {
title: string | React.ReactNode
value: T
}
/** Dropdown Option as if it was an input event TargetStub */
export interface OptionWithName<T extends string = string> {
name: string
value: Option<T>
}
type AnyObject<T = any> = Record<string, T>
import { useCallback } from 'react'
import { useCounter } from './useCounter'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @description This counting state manager toggles between `true` and `false` and counts the number of switches turned on.
* It is useful for asynchronous and concurrent actions as it can be controlled from multiple places and only turns off when every switch is turned off,
* e.g. when every action finishes, etc.., causing a state change, which triggers a re-render.
*
* - `state` - `true` "On" | `false` "Off"
* - `counter` - number of switches turned on, can't go below 0
*
* @example // 1. Initialize the state
*
* const [state, counter, add, remove, adjust, turnOff, reset, refState] = useMultiwaySwitch() // initial state set to Off - `0`
* const [state, , add, , , turnOff] = useMultiwaySwitch(false) // initial state set to Off - `0`
* const [state, , , remove] = useMultiwaySwitch(2) // initial state set to On - `2`
*
* @example // 2. Use the actions
*
* const [...] = useMultiwaySwitch(2) // initial state set to On - `2`
*
* add() // `+1`, alias for adjust(true)
* remove() // `-1`, alias for adjust(false)
*
* adjust(true) // `+1`
* adjust(false) // `-1`
*
* adjust(-2) // counter: 0, state: false // adjust counter value to 0 (can't go below 0)
* adjust(2) // counter: 2, state: true // adjust counter value to 2
* adjust(0) // counter: 0, state: false // alias turnOff
*
* add() // counter: 1, state: true // turns on
* add() // counter: 2, state: true
* remove() // counter: 1, state: true // still on
* remove() // counter: 0, state: false // turns off
* remove() // counter: 0, state: false // no change
*
* reset() // counter: 2, state: true // reset to initial state
* turnOff() // counter: 0, state: false // set's counter to 0
*/
export function useMultiwaySwitch(initialState: number | boolean = 0): [
/** `true` On | `false` Off */
state: boolean,
/** Number of switches turned on. */
counter: number,
/** Adds 1 to the counter. */
add: () => void,
/** Subtracts 1 from the counter. */
remove: () => void,
adjust: {
/**
* Sets the value of the counter.
* @example
* adjust(2) // counter: 2, state: true
* adjust(0) // counter: 0, state: false
*/
(turnTo: number): void
/**
* Adjusts the counter by one. Same as `add()` or `remove()`.
* @example
* adjust(true) // `+1`
* adjust(false) // `-1`
*/
(addOrRemove: boolean): void
},
/** Sets the value of the counter to 0. Alias to `adjust(0)` */
turnOff: () => void,
/** Sets the value of the counter to initial value. Alias to `adjust(initialState)` */
reset: () => void,
/** Escape hatch to make life easier inside hooks and callbacks ¯\_(ツ)_/¯ */
refState: { current: number }
] {
const [counter, add, , change, reset, refState] = useCounter(Math.min(0, Number(initialState)))
const remove = useCallback(() => change(refState.current > 0 ? false : 0), [])
const adjust = useCallback((to: number | boolean) => change(Math.max(0, to as any)), [])
const turnOff = useCallback(() => change(0), [])
return [counter > 0, counter, add, remove, adjust, turnOff, reset, refState]
}
import { useCallback, useEffect, useMemo } from 'react'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @description This hook is used to detect mouse events and tab navigation outside of a given element.\
*
* @param {string} id - The id of the element to detect focus outside of. The element must have an id for this to work.
* @param {function} callback - The callback to execute when a focus outside of the element is detected.
* @param {boolean} shouldAttachListeners - An escape hatch for components that are always mounted. Setting true/false adds/removes listeners.
*
* @example
* // used in a component that itself unmounts and thus automatically unregisters the event listeners.
* useOnFocusOutside('Dropdown', () => setIsDropdownOpen(false))
* // used in a component that is always mounted, listeners are only added when the dropdown is open and removed when it is closed.
* useOnFocusOutside('Dropdown', () => setIsDropdownOpen(false), isDropdownOpen)
*/
export const useOnFocusOutside = (id: string, callback_: () => void, shouldAttachListeners = true) => {
const callback = useMemo(() => debounce(callback_, 100, true), [callback_])
const handleFocusOutside = useCallback(
(e: FocusEvent) => {
const path = e.composedPath?.() ?? (e as FocusEvent & { path: Node[] }).path
if (!path.some((item) => (item as HTMLElement).id === id)) callback()
},
[callback, id]
)
useEffect(() => {
if (shouldAttachListeners) {
window.addEventListener('mousedown', handleFocusOutside)
window.addEventListener('focusin', handleFocusOutside)
}
return () => {
window.removeEventListener('mousedown', handleFocusOutside)
window.removeEventListener('focusin', handleFocusOutside)
}
}, [handleFocusOutside, shouldAttachListeners])
}
// debounce ------------------------------------------------------------------------------------------------------------
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @description Returns a function, that, as long as it continues to be invoked, will not
* trigger the callback. The callback will be called after it stops being called for
* N milliseconds. If `immediate` is passed, trigger the callback on the
* leading edge, instead of the trailing.
*
* `.cancel()` can be called manually to cancel the scheduled trailing invocation.
*
* @param wait - `[milliseconds]`
* @param immediate - Control whether the callback should be triggered on the leading or trailing edge of the wait interval.
*/
export default function debounce<Callback extends AnyFunction>(cb: Callback, wait: number, immediate?: boolean) {
let timeout: NodeJS.Timeout | null
function debounced(this: any, ...args: Parameters<Callback>): void {
const context = this
const later = () => {
timeout = null
if (!immediate) cb.apply(context, args)
}
const callNow = immediate && !timeout
timeout && clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) cb.apply(context, args)
}
debounced.cancel = () => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
}
return debounced
}
type AnyFunction = (...args: any[]) => any
import { useMemo, useReducer, useRef } from 'react'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @params `(initialState)`
* @returns `[queue, refQueue]`
*
* @description This state manager operates on a queue (array),
* implementing custom actions `pop`, `push`, `shift`, `unshift`, that trigger re-renders.
*
* *note:* Standard Array methods are also available, but they don't trigger re-renders and can cause unexpected behavior.\
* ***Don't mutate the queue directly.***
*
* @example
* const [queue, refQueue] = useQueue(['a', 'b', 'c'])
* queue.push('d')
* const token = refQueue.current.shift()
* setToken(token)
*/
export default function useQueue<S extends any = any>(
initialState: S[] = [] as S[],
): [
queue: QueueType<S>,
/** Escape hatch to make life easier inside hooks and callbacks ¯\_(ツ)_/¯ */
refQueue: { current: QueueType<S> }
] {
const [state, setState] = useReducer(
(prevState: S[], action: Action<S>) => {
switch (action.type) {
case 'push': return [...prevState, action.value]
case 'unshift': return [action.value, ...prevState]
case 'pop': return prevState.slice(0, -1)
case 'shift': return prevState.slice(1)
default: return prevState
}
},
initialState,
)
const refState = useRef<S[]>([])
refState.current = useMemo(() => [...state], [state])
const methods = useMemo<QueueMethods<S>>(() => ({
push(value: S) { setState({ type: 'push', value }) },
unshift(value: S) { setState({ type: 'unshift', value }) },
pop() { setState({ type: 'pop' }); return refState.current.at(-1) },
shift() { setState({ type: 'shift' }); return refState.current[0] },
}), [])
const queue = useMemo<QueueType<S>>(() => (
Object.assign([], state, methods) as QueueType<S>
), [state, methods])
const refQueue = useRef<QueueType<S>>(queue)
refQueue.current = queue
return [queue, refQueue]
}
/**
* The type is actually a full Array type, but only these methods are (officially) exposed.
*/
type QueueType<S> = { [index: number]: S } & QueueMethods<S> & Pick<[], 'length'>
interface QueueMethods<S> {
/** Triggers a re-render. */
push: (value: S) => void
/** Triggers a re-render. */
unshift: (value: S) => void
/** Triggers a re-render. */
pop: () => S | undefined
/** Triggers a re-render. */
shift: () => S | undefined
}
type Action<S> = {
type: 'push' | 'unshift'
value: S
} | {
type: 'pop' | 'shift'
}
import { useMemo, useReducer, useRef } from 'react'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @params `(initialState)`
* @returns `[state, setState, utilityFunctions, refState]`
*
* @description This state manager allows to store multiple keys just like
* the old `this.setState()` from React Class Components.
*
* Any state updates are **shallowly merged** with the old state object,
* replacing any old properties and keeping those that did not change.
*
* It also comes preloaded with few **utility functions** and a convenient
* **stable reference** to an always-up-to-date state `refState` that can be used
* inside `useCallback` or `useMemo` without triggering dependency changes.
*
* @example
* const [state, setState] = useShallowState({ a: 1, b: 1 }) // initial state
*
* setState((oldState) => ({ b: oldSate.b + 1 }))
* setState({ c: 3 })
* // state is now {a: 1, b: 2, c: 3}
*/
export default function useShallowState<S extends AnyObject = AnyObject>(
initialState: S = {} as S,
): [
state: S,
setState: SetState<S>,
utilityFunctions: {
/** Sets all properties to `undefined`. */
clearState: () => void
/** Clears property value by setting it to `undefined`. */
clearProperty: (property: keyof S) => void
/** Resets all properties to their `initialState` values. */
resetState: () => void
/** Resets property to its `initialState` value. */
resetProperty: (property: keyof S) => void
/** Deletes all properties from the state object. */
deleteState: () => void
/** Deletes a property from the state object. */
deleteProperty: (property: keyof S) => void
},
/** Escape hatch to make life easier inside hooks and callbacks ¯\_(ツ)_/¯ */
refState: { current: S }
] {
const [state, setState] = useReducer(
(prevState, action = {}) => ({ ...prevState, ...(typeof action === 'function' ? action(prevState) : action) }),
initialState,
) as [S, SetState<S>]
const refState = useRef<S>(state)
refState.current = state
const utilityFunctions = useMemo(() => ({
clearState() { setState(prevState => Object.fromEntries(Object.keys(prevState).map(key => [key, undefined])) as { [key in keyof S]?: undefined }) },
clearProperty(property: keyof S) { setState({ [property]: undefined } as Partial<S>) },
resetState() { setState(prevState => Object.fromEntries(Object.keys(prevState).map(key => [key, initialState[key as keyof S]])) as typeof initialState) },
resetProperty(property: keyof S) { setState({ [property]: initialState[property] } as Partial<S>) },
deleteState() { setState(prevState => void Object.keys(prevState).forEach(key => delete prevState[key])) },
deleteProperty(property: keyof S) { setState(prevState => void delete prevState[property]) },
}), []) // eslint-disable-line react-hooks/exhaustive-deps
return [state, setState, utilityFunctions, refState]
}
export type SetState<S> = (actionOrState?: Partial<S> | ((prevState: S) => Partial<S> | void)) => void
type AnyObject<T = any> = Record<string, T>
import { useCallback, useReducer, useRef } from "react";
/**
* @author Qwerty <qwerty@qwerty.xyz>
*
* @param initialState - `true` | *default:* `false` | `null`
* @returns `[state, toggle, turnOn, turnOff, turn, refState]`
*
* @description This state manager toggles between `true` and `false` automatically,
* but it can also force a value when provided.
*
* @example // 1. Initialize the state
*
* const [state, toggle, turnOn, turnOff, turn, refState] = useToggle() // initial state set to Off - `false`
* const [...] = useToggle(false) // initial state set to Off (default)
* const [...] = useToggle(true) // initial state set to On
* const [...] = useToggle(null) // not On, not Off - useful in some cases
*
* @example // 2. Use the actions
*
* toggle() // toggles state to On - `true`
* toggle() // toggles state to Off - `false`
*
* turnOn() // alias for turn(true)
* turnOff() // alias for turn(false)
*
* turn(true) // turns On - `true`
* turn(true) // keeps On
*
* turn(false) // turns Off - `false`
*
* @example // Special case: `null` state
*
* useToggle<null | boolean>(false) // you can explicitly specify `<null | boolean>`, otherwise boolean is inferred
* useToggle(null) // `<null | boolean>` type is inferred automatically
* turn(null) // null state - not On, not Off
*
* @example
*
* const submitForm = useCallback(() => {
* if (refState.current) // always latest value
* }, [refState]) // never changes reference
*/
export default function useToggle<
Initial extends boolean | null = boolean,
S = Initial extends null ? boolean | null : boolean
>(
initialState: Initial = false as Initial
): [
state: S,
toggle: () => void,
turnOn: () => void,
turnOff: () => void,
turn: (turnTo: S) => void,
refState: { current: S }
] {
const [state, change] = useReducer(
(state_: S, turnTo?: S) => turnTo ?? !state_,
initialState as any
) as [S, (turnTo?: S) => void];
const refState = useRef(state);
refState.current = state;
const toggle = useCallback(() => change(), []);
const turn = useCallback((turnTo: S) => change(turnTo), []);
const turnOn = useCallback(() => change(true as S), []);
const turnOff = useCallback(() => change(false as S), []);
/** All functions and refState are **referentially stable**. */
return [state, toggle, turnOn, turnOff, turn, refState];
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment