Skip to content

Instantly share code, notes, and snippets.

@ackvf
Last active December 8, 2023 00:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ackvf/39049d109d2097c2b0c273190d0c391c to your computer and use it in GitHub Desktop.
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


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

  • 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.

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

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

    after

    const [formState, { onInputChange }] = useFormState<MyFormType>({ name: '', address: '' })
    return (
      <>
        <input name='name' value={formState.name} onChange={onInputChange} />
        <input name='address' value={formState.address} onChange={onInputChange} />
      </>
    )
  • useFormErrors() - Minimalistic, extensible, advanced & type-safe validation library with referentially stable handlers. It is compatible with useFormState hook.

    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.'],
        ],
      }
    )
  • getNodeText()
    react-getNodeText Sandbox screenshot

  • ComponentWithGenerics

  • 🔗 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' />
*
* // after
* const Input: ComponentWithGenerics<React.FC<InputProps>> ...
* <Input<FormState> name='' /> // '' 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) => any
* // 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 ApplyGenericType2<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
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' />
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: ApplyGenericType2<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)
}
// 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 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 emojiRegex from 'emoji-regex'
import { useMemo, useRef } from 'react'
import type { EventStub, useFormState } from './useFormState'
import useShallowState from './useShallowState'
/**
* @author Qwerty <qwerty@qwerty.xyz>
*/
// Regular expression validators and constants -------------------------------------------------------------------------
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 */
const NUMBER = `0-9`
const DECIMAL_TEMPLATE = (decimals?: number) => `^\\d*(\\.\\d${(decimals === undefined) ? '*' as const : `{0,${decimals}}` as const})?$` as const
const TEXT = `a-zA-Z`
const ALNUM = `a-zA-Z0-9`
const SPECIAL_CHARACTERS = `_.,:;~+-=*'"^°\`<>(){}[\\\]!?$@&#%|\\\\/`
const SPECIAL_TEXTAREA_CHARACTERS = `${SPECIAL_CHARACTERS}\n\t•◦‣∙` as const
const EMOJI = emojiRegex()//.toString().slice(1,-2)
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}$";
// 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 = {
isTrue: (value => value == true) as Validator<boolean>,
/** Compare that two fields are equal. */
matchesField: <FormState>(fieldName: keyof FormState) => (value: string, formState: FormState): boolean => value === formState[fieldName],
/* 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> => s => s.length <= length,
minLength: minLength = (length: number): Validator<string> => s => s.length >= length,
notEmpty: minLength(1),
isTrimmed: (s: string) => s.trim() === s,
/* regex */
/** Matches a provided regex. */
isMatching: isMatching = r => s => r.test(s),
isAlphaNumeric: isMatching(c(ALNUM)),
isNumeric: isMatching(c(NUMBER)),
isDecimal: (decimals?: number) => isMatching(r(DECIMAL_TEMPLATE(decimals))),
isAddress: isMatching(r(ADDRESS)),
isText: isMatching(c(TEXT)),
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 = {
/** 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.'],
onlyAddress: [check.isAddress, 'Must be a wallet address.'],
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,
onlyAlphaNumeric: [check.isAlphaNumeric, 'Only alphanumeric characters allowed.'],
onlyText: [check.isText, 'Only english letters are allowed.'],
/** 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>
// ---------------------------------------------------------------------------------------------------------------------
// 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 useFormState} for more advanced use case.
*
* @example
* // This is a simplified example
* // without integration with `useFormState()` hook.
* // See 👉`useFormState.ts` for more advanced use case.
*
* // It's really intuitive and easy to use:
*
* const [, {getFormErrors}] = useFormErrors<FormState>()
*
* const formErrors = getFormErrors(
* // formState //
* {
* name: 'yolo',
* confirmName: 'nope',
* },
* // validation rules //
* {
* 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.'],
* ],
* }
* )
*/
export function useFormErrors<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 `useFormState` `change` callback. E.g. `useFormState(...,{ change: 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(formState) 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)
.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> = [validator: Validator<Value, State>, message: string] | readonly [validator: Validator<Value, State>, 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 type { ChangeEventHandler, Reducer } from 'react'
import { useEffect, useMemo, useReducer, useRef } from 'react'
/**
* @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] = useFormState<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 }] = useFormErrors<CollectionFormState>(validationRules)
*
* const [formState, { onInputChange, onDropDownChange, onToggle }, { resetForm }] = useFormState<CollectionFormState>(
* emptyCollectionForm,
* initialFormState,
* { change: checkFieldErrorsOnFormStateChange }
* )
*/
export function useFormState<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 on the input. */
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 useFormErrors.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 { 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.
*/
export default function useShallowState<S extends AnyObject = AnyObject>(
initialState: S = {} as S,
): [
state: S,
setState: typeof setState,
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,
},
/** 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, (actionOrState?: Partial<S> | ((prevState: S) => Partial<S>)) => void]
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>) },
}), []) // eslint-disable-line react-hooks/exhaustive-deps
return [state, setState, utilityFunctions, refState]
}
type AnyObject<T = any> = Record<string, T>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment