Skip to content

Instantly share code, notes, and snippets.

@ackvf
Last active January 29, 2026 03:26
Show Gist options
  • Select an option

  • Save ackvf/39049d109d2097c2b0c273190d0c391c to your computer and use it in GitHub Desktop.

Select an option

Save ackvf/39049d109d2097c2b0c273190d0c391c to your computer and use it in GitHub Desktop.
React utils

React Utils

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

note: Any ambient types referenced in the code samples below are defined in global.d.ts from the TypeScript type toolbelt.

Summary

Hooks

State Managers

  • useLessFormState - Signature, minimalistic, extensible, advanced & type-safe form-state management library with referentially stable handlers. Seamlessly integrates with useLessFormErrors.
  • useForceUpdate - This void state manager allows to trigger a re-render of a component.
  • useShallowState - Replace all useState with this one that stores 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.
  • useToggle - Primitive boolean* state manager built with useReducer with referentially stable convenience handlers. (*also supports null)
  • useQueue - This state manager operates on a queue (array), implementing custom actions pop, push, shift, unshift and set, that trigger re-renders.
  • useCounter - This is a simple counting state manager. Referentially stable.
  • 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..

Utils

  • useLessFormErrors - Signature, minimalistic, extensible, advanced & type-safe validation library with referentially stable handlers. Seamlessly integrates with useLessFormState.
  • useDebugDependencies - Signature tool to analyze which hook dependencies trigger an update. Can also be used with ordinary arrays or objects to keep track or visualize changes.
  • useOnFocusOutside - This hook is used to detect mouse events and tab navigation outside of a given element.
  • useIsClientRendererReady - This hook detects whether the React renderer is ready on the client side. Useful for SSR applications to avoid hydration mismatches.

Higher Order Components

  • withRequired - Conditionally renders the wrapped component when specified props are not undefined. This is a way to avoid rules of hooks for conditional hooks usage based on props.
  • withDebounce - Debounces the props change of a wrapped component. Useful for Charts and other heavy components when parent or the data updates too frequently.

Functions & Types

  • getNodeText - Function that extracts and concatenates all text content from a React node, including nested elements.
  • ComponentWithGenerics - Type helper that allows to pass a generic type to a component and use it in the component's props to support smarter type checking and intellisense.

Detailed description and showcase

Hooks

  • 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
  • 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} />
      </>
    )
  • 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.'],
        ],
      }
    )
  • 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>
  • 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 }
  • 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>
  • This state manager operates on a queue (array), implementing custom actions pop, push, shift, unshift and set, 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
  • 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
  • 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.
  • Detect mouse events and tab navigation outside of a given element.
    useOnFocusOutside('Dropdown', () => closeDropdown())
    
    useOnFocusOutside(['Sidebar', 'Table'], closeSidebar, { click: true, escapeKey: true, focus: true })
  • Hooks only run on the client side, so this hook can be used to determine:

    • if the component is rendered on the client side, and
    • when the renderer is ready (i.e., after the first render).

    Useful for SSR applications to avoid hydration mismatches.

    const isClientReady = useIsClientRendererReady()
    if (!isClientReady) return <LoadingSpinner />
    return <ClientOnlyComponent />

Higher order Components

  • Higher Order Component that conditionally renders the wrapped component based on the presence of a specific prop. This aims to solve the problem of conditions before hooks in React components.

    interface MyComponentProps {
      required?: string
      optional?: string
    }
    
    export MyComponent = withRequired<MyComponentProps, "required">("required")(function MyComponent({ required, optional }) {
     // required: string              // <- guaranteed to be defined
     // optional: string | undefined  // <- remains optional
    })
  • Higher Order Component to debounce prop changes before rerendering the wrapped component.
    const DebouncedChart = withDebounce(500)(ChartComponent)

Functions & Types


Resources:
Custom anchors in markdown ^

// v3, Dec 8 2023 ------------------------------------------------------------------------------------------------------
/**
* This is an improved version tailored specifically for use with [useLessFormState()](#file-hooks-uselessformstate-ts)
*
* Provides better intellisense for JSX Components at call-site by allowing them to accept additional generics for refined type checking.
*
* 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 {name, address}
*
* // before
* <Input name='yolo' />
*
* // after
* <SliderInput<FormState>
* name='yolo' // 'yolo' is not assignable to 'name' | 'address'
* onChange={(value: string) => ...} // type (value: string) => void is not assignable to (ev: EventStub) => void
* />
*/
type UselessFormInput<C extends React.FC<any>> = <FormState extends AnyObject = { ___: true }>(...params: FormState extends { ___: true } ? Parameters<C> : [Parameters<C>[0] & { name: keyof FormState }]) => ReturnType<C>
// v1, Jan 18 2023 -----------------------------------------------------------------------------------------------------
/**
* Provides better intellisense for JSX Components at call-site by allowing them to accept additional generics for refined type checking.
*
* @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, Feb 3 2023 ------------------------------------------------------------------------------------------------------
/**
* @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'.
* @example
* // 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 and ensures that the generic type `MyGeneric` at least partially matches the original generic type extracted from the original component `C`.
*
* The trick is `extends Partial<...>`, which does nothing but provide better type checking and intellisense.
* Removing `Partial` will make it so that the generic is *required* to provide overrides for all the original props too.
*/
type ApplyGenericTyped<C extends React.FC<any>> = <MyGeneric extends Partial<ExtractGenerics<C>> = ExtractGenerics<C>>(
props: Parameters<C>[0] & MyGeneric
) => ReturnType<C>
type ApplyGenericTyped2<C extends React.FC<any>> = <MyGeneric extends ExtractGenerics<C> = ExtractGenerics<C>>(
props: Parameters<C>[0] & MyGeneric
) => ReturnType<C>
// ---
type AnyObject<T = any> = Record<string, T>
type ExtractGenerics<T> = T extends React.FC<infer U> ? U : never
// TEST ----------------------------------------------------------------------------------------------------------------
interface Props {
gmi: string
}
interface Narrowed {
gmi: 'GMI' | 'NGMI'
}
type T00 = ExtractGenerics<React.FC<Props>>
type T01 = Parameters<React.FC<Props>>[0] & Narrowed
type T02 = ReturnType<React.FC<Props>>
// --- Difference between ApplyGenericType and ApplyGenericTyped ---
const Gx: ApplyGenericType<React.FC<Props>> = ({ }) => <>OK</>
const Gy: ApplyGenericTyped<React.FC<Props>> = ({ }) => <>OK</>
const t41 = <Gx<{ gg }> gmi="" /> // Can specify new prop in Generic
const t42 = <Gy<{ gg }> gmi="" /> // Cannot specify new prop, because it doesn't extend Partial<Props>
interface Props2 { gmi: string, ngmi: string }
const Gx2: ApplyGenericType<React.FC<Props2>> = ({ }) => <>OK</>
const Gy2: ApplyGenericTyped<React.FC<Props2>> = ({ }) => <>OK</>
const Gz2: ApplyGenericTyped2<React.FC<Props2>> = ({ }) => <>OK</>
const t43 = <Gx2<{ gmi: "GMI" }> gmi="GMI" /> // Can specify partial override in Generic
const t44 = <Gy2<{ xxx: string }> gmi="GMI" /> // Cannot specify new prop, because it doesn't extend Partial<Props2>
const t45 = <Gy2<{ gmi: "GMI", xxx: string }> gmi="GMI" /> // Once we specify an existing prop, we can also add new ones // this is bad
const t46 = <Gz2<{ gmi: string, ngmi: string, xxx: string }>/> // Must specify all overrides in Generic, still allows new props // also bad
// --- TEST ApplyGenericType
const t01 = <Gx<Narrowed> gmi='NGMI' /> // OK
const t02 = <Gx<Narrowed> gmi='anything' /> // ERROR: Type '"anything"' is not assignable to type '"GMI" | "NGMI"'.
const t03 = <Gx gmi='anything' /> // no type checking
const t04 = <Gx<{gmi: "gmi", yolo: "gmi"}> gmi='gmi' yolo="gmi"/> // Narrow existing type
const t05 = <Gx<{yolo: "gmi"}> gmi='hi' yolo="gmi" /> // Add new prop without requiring to redefine existing ones
const t11 = <Gx<Narrowed> gmi='' /> // <- 👈🔍 try invoking intellisense here - gives 'NGMI | GMI'
const t12 = <Gx gmi='' /> // <- 👈🔍 try invoking intellisense here - gives nothing
const t13 = <Gx<{yolo: "gmi"}> gmi='' yolo="" /> // <- 👈🔍 try invoking intellisense here - gives 'gmi'
// --- TEST ApplyGenericTyped
const t21 = <Gy gmi='hi'/>
const t22 = <Gy<{gmi:"gmi", yolo: "gmi"}> gmi='gmi' yolo="gmi"/>
const t23 = <Gy<{yolo: "gmi"}> gmi='gmi' yolo="gmi"/> // Cannot specify new prop, because it doesn't extend Partial<Props>
const t31 = <Gy<Narrowed> gmi='' /> // <- 👈🔍 try invoking intellisense here - gives 'NGMI | GMI'
const t32 = <Gy gmi='' /> // <- 👈🔍 try invoking intellisense here - gives nothing
Gy({})
Gy<Narrowed>({gmi:'NGMI'})
Gy<{ gmi: "gmi", yolo: "gmi" }>({})
// ---

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)
    }

    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 React, { useEffect, useState } from "react"
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hoc-withdebounce-tsx)
*
* @description
* ### Higher Order Component to debounce prop changes before rendering the wrapped component.
*
* @param wait debounce delay in `ms`
*
* @example
* const DebouncedChart = withDebounce(500)(ChartComponent)
*/
export function withDebounce(wait: number) {
return function <C extends React.ComponentType<any>>(Component: C) { // eslint-disable-line @typescript-eslint/no-explicit-any
return function WithDebounce(props) {
const [debouncedProps, setDebouncedProps] = useState(props)
useEffect(() => {
const handler = setTimeout(setDebouncedProps, wait, props)
return () => clearTimeout(handler)
}, [props])
return <Component {...debouncedProps} />
} as C
}
}
import React, { useEffect, useState } from "react"
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hoc-withrequired-tsx)
*
* @description
* ### Higher Order Component that conditionally renders the wrapped component based on the presence of a specific prop.
*
* This aims to solve the problem of ***conditions before hooks*** in React components.
*
* The *optional* prop is then inferred to be *required* in the wrapped component, avoiding the need for additional type assertions.
*
* 🚨 **note:** When using the **accessor function**, make sure that it returns `undefined`.
* ***Any*** other value, custom boolean logic or simply returning falsy values like `null`, `false`, `""` or `0` will evaluate the condition to `true`.
*
* @param key The prop key to check for presence
*
* @example
*
* export default withRequired<YourComponentProps, "yourPropKey">("yourPropKey")(YourComponent)
*
* @example
* interface MyComponentProps {
* required?: string
* optional?: string
* }
*
* /// ## Using a string key
* const MyComponent = withRequired<MyComponentProps, "required">("required")(function MyComponent({ required, optional }) { })
* // required: string // <- guaranteed to be defined
* // optional: string | undefined // <- remains optional
*
* /// ## Using an accessor function with two props returning `undefined`
* const MyComponent = withRequired<MyComponentProps, "required" | "optional">(p => p.required && p.optional)(function MyComponent({ required, optional }) { })
* // required: string // <- both guaranteed to be defined
* // optional: string
*/
export function withRequired<P extends AnyObject, K extends keyof P>(key: K): <T extends WithRequired<P, K>>(Component: React.ComponentType<T>) => (props: P) => React.ReactNode
/**
* 👉 Accessor function allows to check multiple props at once.
*
* 🚨 **note:** Make sure that your **accessor function** returns a *value* or `undefined`.
* ***Anything*** other than `undefined` will render the component.
*
* @param accessor The accessor function that returns a prop value or `undefined`
*
* @example
* interface MyComponentProps {
* required?: string
* optional?: string
* }
*
* export const MyComponent = withRequired<MyComponentProps, "required" | "optional">(p => p.required && p.optional)(function MyComponent({ required, optional }) { })
* // required: string // <- both guaranteed to be defined
* // optional: string
*/
export function withRequired<P extends AnyObject, K extends keyof P>(accessor: Accessor<P, K>): <T extends WithRequired<P, K>>(Component: React.ComponentType<T>) => (props: P) => React.ReactNode
export function withRequired<P extends AnyObject, K extends keyof P>(k_a: Accessor<P, K>) {
return function <T extends WithRequired<P, K>>(Component: React.ComponentType<T>) {
return function WithRequired(props: P) {
if ((typeof k_a === "function" ? k_a(props) : props[k_a]) === undefined) return null
return <Component {...props as unknown as T} />
}
}
}
type Accessor<P, K extends keyof P> = (props: Pick<P, K>) => P[K]
/** Keep original Type, but make specified keys required. */
type WithRequired<P, K extends keyof P> = Omit<P, K> & { [key in K]-?: P[key] }
type AnyObject<T = any> = Record<string, T>
import { useCallback, useReducer, useRef } from 'react'
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-usecounter-ts)
*
* @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
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RefObject } 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 {
/** Disable console.log output, useful for manual analysis. */
disableLog?: boolean
/** Optional name to identify the whole hook in console logs. */
name?: string
}
// TODO: That useRef(generateArray) could be optimized to not create new arrays on every render.
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-usedependencies-ts)
*
* @description
* This hook accepts another hook's dependency array, returns data for manual analysis
* and prints comprehensive analytical logs to console.
*
* There are four variants, the latter two are convenience shorthands with configuration preset.
* - `useDebugDependencies(dependencies, names?, config?)`
* - `useDebugDependencies(dependencyObject, config?)` // dependencies as object automatically provide names
* - `useDependencyHook([...,] dependencies)` // see advantages below, logs to console
* - `data = useDependencySilentHook([...,] dependencies)` // -||-, doesn't do console.log, useful for manual analysis
*
* ## `useDebugDependencies`
* Provide array of names, or convenient dependency object for better logs.\
* Returns `dependencyAnalysis` object for further processing.
*
* @example
* useDebugDependencies([chainId, address], ['chainId', 'address'], { name: 'HERE' })
* useDebugDependencies({chainId, address}, config) // <- dependencies as object
* const dependencyAnalysis = useDebugDependencies([chainId, address])
* console.log(dependencyAnalysis.changed)
*
* @description
* ## `useDependency[Silent]Hook` shorthand
* The advantage of **`useDependencyHook`** or **`useDependencySilentHook`** is to be used directly with original code.
* It extracts only the last parameter, assuming it to be the dependency array.
*
* @example
* // before (original hook, dependency array)
* const memoized = useMemo(() => {...}, [chainId, address])
* // after (same props)
* const analysis = useDependencyHook(() => {...}, [chainId, address])
* console.log(analysis)
*
* @example
* // These are equivalent
* useDependencyHook(...args, [chainId, address])
* useDebugDependencies([chainId, address])
*
* // These are equivalent, with silent config
* const { isDirty } = useDependencySilentHook([chainId, address])
* const { changed } = useDebugDependencies([chainId, address], undefined, { disableLog: true })
* console.log('changed:', isDirty, changed)
*/
export function useDebugDependencies<T extends any[]>(dependencies: [...T], names?: Partial<ReTuple<T, string>>, config?: Config): DependencyAnalysis<T> & { refs: RefObject<DependencyAnalysis<T>> }
export function useDebugDependencies<O extends AnyObject, T extends Array<keyof O> = Array<keyof O>>(dependencyObject: O, config?: Config): DependencyAnalysis<T> & { refs: RefObject<DependencyAnalysis<T>> }
export function useDebugDependencies<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 useDebugDependencies
export const useDependencyHook /* */ = (...args: [...any[], DependencyArray]) => useDebugDependencies(args.pop())
export const useDependencySilentHook = (...args: [...any[], DependencyArray]) => useDebugDependencies(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 <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-useforceupdate-ts)
*
* @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 { useEffect, useState } from "react"
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-useisclientrendererready-ts)
*
* @returns {boolean} `true` if the renderer is ready on the *client side*, `false` otherwise.
*
* @description
*
* Hooks only run on the client side, so this hook can be used to determine
* 1. if the component is rendered on the client side, and
* 2. when the renderer is ready (i.e., after the first render)
*/
export function useIsClientRendererReady() {
const [ready, setReady] = useState(false)
useEffect(() => { setReady(true) }, []) // eslint-disable-line react-hooks/set-state-in-effect
return ready
}
import { useMemo, useRef } from 'react'
import emojiRegex from 'emoji-regex'
import type { EventStub, useLessFormState } from './useLessFormState'
import useShallowState from './useShallowState'
/**
* @author Qwerty <[email protected]>
*/
// 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 <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-uselessformerrors-ts)
*
* **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 <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-uselessformstate-ts)
*
* **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' /** @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-usecounter-ts) */
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-usemultiwayswitch-ts)
*
* @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, useRef } from 'react'
type Options = {
/** Triggered by mouse click. *default: `true`* */
click?: boolean
/** Triggered by focus change (e.g., tab navigation). *default: `true`* */
focus?: boolean
/** Triggered by leaving the element with the mouse. *default: `false`* */
hover?: boolean
/** Triggered by pressing the Escape key. *default: `true`* */
escapeKey?: boolean
}
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-useonfocusoutside-ts)
*
* @description This hook is used to detect mouse events and tab navigation outside of a given element. Formerly `onClickOutside`.
*
* @param {string | string[]} id - The id or array of ids of the elements to detect focus outside of. The elements 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 {Options} options - Configuration options for the hook.
* @param {boolean} shouldAttachListeners - An escape hatch for components that are always mounted. Changing between true/false adds/removes listeners.
*
* @example
* const hideDropdown = useCallback(() => setIsDropdownOpen(false), []) // memoized callback for reference stability
* // used in a component that is un/mounted by a parent and thus automatically unregisters its event listeners.
* useOnFocusOutside(['Sidebar', 'Table'], hideSidebar)
* // used in a component that is always mounted, listeners are only added when a state value is changed to `true` and removed when changed to `false`.
* const [isDropdownOpen, setIsDropdownOpen] = useState(false)
* useOnFocusOutside('Dropdown', hideDropdown, isDropdownOpen)
*/
export default function useOnFocusOutside(id: string | string[], callback: () => void, shouldAttachListeners?: boolean): void
export default function useOnFocusOutside(id: string | string[], callback: () => void, options: Options, shouldAttachListeners?: boolean): void
export default function useOnFocusOutside(id: string | string[], callback: () => void, optionsOrBoolean: Options | boolean = {} as Options, shouldAttachListeners: boolean = true): void {
shouldAttachListeners = typeof optionsOrBoolean === 'boolean' ? optionsOrBoolean : shouldAttachListeners
const options = {
click: true,
focus: true,
hover: false,
escapeKey: true,
...optionsOrBoolean as object,
} satisfies Options
const callbackRef = useRef<AnyFunction>(null!)
callbackRef.current = callback
const idRef = useRef<string[]>(null!)
idRef.current = Array.isArray(id) ? id : [id]
const handleFocusOutside = useCallback(
(e: FocusEvent) => {
const path = e.composedPath?.() ?? (e as FocusEvent & { path: Node[] }).path
if (!path.some(el => idRef.current.some(id => (el as HTMLElement).id === id))) callbackRef.current()
},
[]
)
const handleEscapeKey = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === 'Esc') {
callbackRef.current()
}
}, [])
useEffect(() => {
if (shouldAttachListeners) {
if (options.click) window.addEventListener('mousedown', handleFocusOutside)
if (options.focus) window.addEventListener('focusin', handleFocusOutside)
if (options.hover) window.addEventListener('mousemove', handleFocusOutside)
if (options.escapeKey) window.addEventListener('keydown', handleEscapeKey)
}
return () => {
window.removeEventListener('mousedown', handleFocusOutside)
window.removeEventListener('focusin', handleFocusOutside)
window.removeEventListener('mousemove', handleFocusOutside)
window.removeEventListener('keydown', handleEscapeKey)
}
}, [shouldAttachListeners, options.click, options.escapeKey, options.focus, options.hover])
}
type AnyFunction<R = any> = (...args: any[]) => R
import { useMemo, useReducer, useRef } from 'react'
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-usequeue-ts), [playground](https://codesandbox.io/p/sandbox/rw65nx)
*
* @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.\
* If you mutate the queue directly, the internal state won't update accordingly. Use the `set` method to replace the queue after mutations.
*
* @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)
case 'set': return [...action.value]
default: return prevState
}
},
initialState,
)
const refState = useRef<S[]>(undefined!)
refState.current = 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] },
set(newQueue: S[]) { setState({ type: 'set', value: newQueue }) },
}), [])
// This fresh array `[]` prevents mutations from changing the original state.
const queue = useMemo<QueueType<S>>(() => (
Object.assign([], state, methods) as QueueType<S>
), [state])
const refQueue = useRef<QueueType<S>>(queue)
refQueue.current = queue
return [queue, refQueue]
}
/**
* Technically, the array is actually a full Array type even though we only implement basic methods.\
* Note that standard mutating Array methods don't mutate the internal state or trigger re-renders.
* Use the `set` method to replace the queue after mutations.
*/
export type QueueType<S> = { [index: number]: S } & QueueMethods<S> & Omit<[], 'push' | 'pop' | 'shift' | 'unshift'>
interface QueueMethods<S> {
/** Pushes a value to the end of the queue and triggers a re-render. */
push: (value: S) => void
/** Pushes a value to the front of the queue and triggers a re-render. */
unshift: (value: S) => void
/** Removes the last value from the queue and returns it. Triggers a re-render. */
pop: () => S | undefined
/** Removes the first value from the queue and returns it. Triggers a re-render. */
shift: () => S | undefined
/** Directly sets the queue to a new array. Useful after mutating with methods such as `sort`, `reverse`, `splice`... */
set: (newQueue: S[]) => void
}
type Action<S> = {
type: 'push' | 'unshift'
value: S
} | {
type: 'pop' | 'shift'
} | {
type: 'set'
value: S[]
}
import { useMemo, useReducer, useRef } from 'react'
/**
* @author Qwerty <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-useshallowstate-ts)
*
* @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 <[email protected]>
* @link [gist](https://gist.github.com/ackvf/39049d109d2097c2b0c273190d0c391c#file-hooks-usetoggle-ts)
*
* @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