0.6.14 • Published 5 months ago

crustack v0.6.14

Weekly downloads
-
License
ISC
Repository
-
Last release
5 months ago

crustack

Type safe React Components & utilities

⚠️ This package is ESM only and requires typescript 5+

Table of contents

➡ State & Data flow

✔ defineSharedState

The Shared State factory. Just like useState with the defaults being defined upfront.

Great for SSR to opt into "use client" as late as possible.

'use client'
import { defineSharedState } from 'crustack/shared-state'

// the type definition has to be done upfront
type SharedState = {
  count: number
  isSidebarOpen: boolean
}

// pass it to `defineSharedState`
export const { useSharedState, SharedStateProvider } =
  defineSharedState<SharedState>()

Place the provider anywhere in your app. The provider uses refs under the hook, this way it does not trigger unnecessary rerenders.

function Homepage() {
  return (
    <SharedStateProvider defaultValues={{ count: 0, isSidebarOpen: false }}>
      <Counter />
      <Counter />
      <Counter />
    </SharedStateProvider>
  )
}

All consumers "share" the state and are synchronized

"use client"
import { useSharedState } from "./shared-state"

function Counter() {
  const [count, setCount] = useSharedState('count')

  return (
    <button onClick={()=> setCount(c=> ++c)}>
      Current count: {count}
    </buttons>
  )
}

✔ defineStorage (SPA only)

Similar to defineSharedState except that the state persists using either the localStorage or the sessionStorage.

If you need SSR use defineCookies instead.

'use client'
import { defineStorage } from 'crustack/storage'

const { LocalStorageProvider, useLocalStorage, useClearLocalStorage } =
  defineStorage(
    {
      type: 'local', // or 'session'
    },
    {
      count: { validate: (value) => Number(value) },
      isOpen: { validate: (value) => Boolean(value) },
    },
  )

You most likely want to place the Provider at the top level of your app

function App() {
  return (
    <LocalStorageProvider>
      <Counter />
      <Counter />
      <Counter />
    </LocalStorageProvider>
  )
}

All consumers "share" the state and are synchronized

"use client"
import { useLocalStorage } from "./local-storage"

function Counter() {
  const [count, setCount] = useLocalStorage('count')

  return (
    <button onClick={()=> setCount(c=> ++c)}>
      Current count: {count}
    </buttons>
  )
}

Clear the Storage

import { useClearLocalStorage } from './local-storage'

const clearLocalStorage = useClearLocalStorage()

// clear ALL user defined keys
clearLocalStorage()

// clear ONLY specific keys
clearLocalStorage({ keys: ['count'] })
defineStorage(
  {
    type: 'local',
    serialize: (value) => {
      /* stringify and return the value */
    },
    deserialize: (value) => {
      /* parse and return the value */
    },
  },
  {
    /* config here */
  },
)

Zod

import { z } from 'zod'

defineStorage(
  {
    type: 'local',
  },
  {
    count: {
      // expect a number, fallback to 0
      validate: (value) => z.number().parse(value).catch(0),
    },
    isOpen: {
      // expect a boolean, fallback to false
      validate: (value) => z.boolean().parse(value).catch(false),
    },
  },
)

Zod

import { parse, number, boolean, fallback } from 'zod'

defineStorage(
  {
    type: 'local',
  },
  {
    count: {
      // expect a number, fallback to 0
      validate: (value) => fallback(parse(number(), value), 0),
    },
    isOpen: {
      // expect a boolean, fallback to false
      validate: (value) => fallback(parse(boolean(), value), false),
    },
  },
)

✔ defineCookies (SSR only)

Similar to defineSharedState except that the state is persisted using the cookies.

If you have a SPA (no SSR) use defineStorage instead.

'use client'
import { defineCookies } from 'crustack/cookies'

const { CookieProvider, useCookie, useClearCookies } = defineCookies(
  {}, // see custom serialize / deserialize for this
  {
    count: {
      validate: (value) => Number(value),
      expires: 365, // day
    },
    isOpen: {
      validate: (value) => Boolean(value),
      expires: new Date('2026-12-12'), // specific date
      secure: true,
      domain: 'example.com',
      sameSite: 'lax',
    },
  },
)

Place the Provider at the top level of your app, and provide the server side cookies during SSR

// Next.js example
import { cookies } from 'next/headers'

// this is a server component
export function Providers(props: { children: ReactNode }) {
  const ssrCookies = Object.fromEntries(
    cookies()
      .getAll()
      .map((cookie) => [cookie.name, cookie.value]),
  )
  return <CookieProvider ssrCookies={ssrCookies}>{children}</CookieProvider>
}

All consumers "share" the state and are synchronized

'use client'
import { useCookies } from './cookies'

export default function SomePage() {
  return (
    <div>
      <Counter />
      <Counter />
      <Counter />
    </div>
  )
}

function Counter() {
  const [count, setCount] = useCookies('count')

  return (
    <button onClick={() => setCount((c) => ++c)}>Current count: {count}</button>
  )
}

Clear the Cookies

import { useClearCookies } from './cookies'

const clearCookies = useClearCookies()

// clear ALL user defined keys
clearCookies()

// clear ONLY specific keys
clearCookies({ keys: ['count'] })
defineCookies(
  {
    serialize: (value) => {
      /* stringify and return the value */
    },
    deserialize: (value) => {
      /* parse and return the value */
    },
  },
  {
    /* config here */
  },
)

Zod

import { z } from 'zod'

defineCookies(
  {},
  {
    count: {
      // expect a number, fallback to 0
      validate: (value) => z.number().parse(value).catch(0),
    },
    isOpen: {
      // expect a boolean, fallback to false
      validate: (value) => z.boolean().parse(value).catch(false),
    },
  },
)

Zod

import { parse, number, boolean } from 'zod'

defineCookies(
  {},
  {
    count: {
      // expect a number, fallback to 0
      validate: (value) => fallback(parse(number(), value), 0),
    },
    isOpen: {
      // expect a boolean, fallback to false
      validate: (value) => fallback(parse(boolean(), value), false),
    },
  },
)

✔ defineEmitter

The PubSub pattern, type-safe, reactified.

'use client'
import { defineEmitter } from 'crustack/emitter'

// define the channel to message map
type ChannelToMessageMap = {
  channel1: string
  channel2: Record<string, string>
  channel3: number[]
}

const { useEmitter, withEmitter, EmitterProvider } =
  defineEmitter<ChannelToMessageMap>()

Place the provider

function App() {
  return <EmitterProvider>...</EmitterProvider>
}

Use the emitter

"use client"

function EmitterComponent() {
  const emitter = useEmitter()

  return (
    <button onClick={() => emitter.emit('channel1', 'my message')}>
      Emit a message to channel1
    </button>
  )
}

function ListenerComponent() {
  useEmitter({
    listen(channel, message) {
      // listen to channel1
      if (channel !== 'channel1') return
      // message is of type string, as defined in ChannelToMessageMap
      console.log(message) // logs 'my message'
    },
  }))

  return (
    <div>...</div>
  )
}

➡ UI

✔ HtmlDialog

The html <dialog> element, cross browser, reactified

function Dialog() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open</button>

      <HtmlDialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
        Hello World!
      </HtmlDialog>
    </div>
  )
}
PropDefaultdescription
isOpen(required)Control the open state
onClose(required)Called when the dialog is closed
mandatoryfalsePrevent closing the dialog on "Esc" or backdrop click
preventAutofocustruePrevents the autofocus on the first focusable element in the dialog. Focuses an hidden element instead

✔ defineCheckbox

Generate lightweight checkbox utilities to build stylable & accessible checkbox components.

The utilities come with minimalistic styles to position each element properly

!IMPORTANT The order of the elements should not change in order to make use of the "peer" selector

import { defineCheckbox } from 'crustack/checkbox'
import { cn } from 'crustack/utils'
import { FaCheck } from 'react-icons/fa6'

const checkbox = defineCheckbox({
  hitboxPadding: '0.5rem', // the clickable area is 0.5rem larger than the checkbox
})

type Props = React.ComponentPropsWithoutRef<'input'>

export function Checkbox({ className, ...props }: Props) {
  return (
    <checkbox.Root className={cn(className, 'size-5')}>
      {/* Hidden input, spread to props here */}
      {/* hitboxPadding is applied on this element */}
      <checkbox.Input
        {...props}
        className="peer cursor-pointer disabled:pointer-events-none"
      />

      {/* The visible part */}
      {/* `checkbox.Box` size is 100% of `checkbox.Root` size */}
      <checkbox.Box
        className={cn(
          // base styles
          'pointer-events-none rounded border border-current transition-all',
          // hover styles
          'peer-hover:bg-base-200',
          // focus visible styles
          'peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-current',
          // invalid styles
          'peer-aria-invalid:text-error',
          // disabled styles
          'peer-disabled:opacity-50 peer-disabled:grayscale',
          // icon styles (according to "checked")
          '[&_svg]:scale-0 peer-checked:[&_svg]:scale-100',
        )}>
        <checkbox.Icon>
          <FaCheck className="size-3/5 transition-all" />
        </checkbox.Icon>
      </checkbox.Box>
    </checkbox.Root>
  )
}

With indeterminate support

+ "use client"
  import { defineCheckbox } from 'crustack/checkbox'
  import { cn } from 'crustack/utils'
  import { FaCheck } from 'react-icons/fa6'

  const checkbox = defineCheckbox({
    hitboxPadding: '0.5rem', // the clickable area is 0.5rem larger than the checkbox
  })

- type Props = React.ComponentPropsWithoutRef<'input'>
+ type Props = React.ComponentPropsWithoutRef<'input'> & {
+   checked: boolean | 'indeterminate'
+ }

  export function Checkbox({ className, ...props }: Props) {
+   const inputProps = checkbox.useIndeterminate(props.checked)

    return (
      <checkbox.Root className={cn(className, 'size-5')}>
        {/* Hidden input, spread to props here */}
        {/* hitboxPadding is applied on this element */}
        <checkbox.Input
          {...props}
+         {...inputProps}
          className="peer cursor-pointer disabled:pointer-events-none"
        />

        {/* The visible part */}
        {/* `checkbox.Box` size is 100% of `checkbox.Root` size */}
        <checkbox.Box
          className={cn(
            // base styles
            'pointer-events-none rounded border border-current transition-all',
            // hover styles
            'peer-hover:bg-base-200',
            // focus visible styles
            'peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-current',
            // invalid styles
            'peer-aria-invalid:text-error',
            // disabled styles
            'peer-disabled:opacity-50 peer-disabled:grayscale',
+           // indeterminate styles
+           'peer-indeterminate:...',
            // icon styles (according to "checked")
            '[&_svg]:scale-0 peer-checked:[&_svg]:scale-100',
          )}>
          <checkbox.Icon>
-           <FaCheck className="size-3/5 transition-all" />
+           {inputProps.checked
+             ? <FaCheck className="size-3/5 transition-all" />
+             : <FaMinus className="size-3/5 transition-all" />
+           }
          </checkbox.Icon>
        </checkbox.Box>
      </checkbox.Root>
    )
  }

✔ defineForm

Build Accessible and type-safe forms.

Example:

Generate typed form components and hooks

import { defineForm } from 'crustack/form'

// define a form that has:
// - a "title" field that has a string value
// - a "description" field that has a string value
type FormValues = { title: string; description: string }
// The type of the submission error, raised in `onSubmit`
type SubmissionError = { errorMessage: string }
// The type of the submission data, returned by `onSubmit`
type SubmissionData = { successMessage: string }

export const todoForm = defineForm<
  FormValues,
  SubmissionError,
  SubmissionData
>()

Make your form Controls accessible

import { useFieldControlAccessibilityProps } from 'crustack/form'

// This Input can still be used outside of a form
export function Input(props: React.ComponentProps<'input'>) {
  const accessibilityProps = useFieldControlAccessibilityProps(props)
  return <input {...props} {...accessibilityProps} />
}

Build your form.

The Label, Description and ErrorMessage components are linked to the inputs via aria-attributes. They should be used within a Field

import { todoForm } from './form'
import { postTodo } from './api'
import { Input } from './input'

function MyTodoForm() {
  return (
    <form.Root initialValues={{ email: '', password: '' }} onSubmit={postTodo}>
      {(ctx) => (
        <form.Form>
          <form.Field
            name="title"
            validate={(title) => title.length < 5 && 'Too short'}>
            <form.Label children="Title" />
            <form.Description children="Add a title to your new todo" />
            <Input
              value={ctx.getFieldValue('title')}
              onChange={(e) => ctx.setFieldValue('title', e.target.value)}
              onBlur={() => ctx.setFieldTouched('title', true)}
            />
            <form.ErrorMessage />
          </form.Field>

          <form.Field
            name="description"
            validate={(title) => title.length < 20 && 'Too short'}>
            <form.Label children="Description" />
            <form.Description children="The description of your new todo" />
            <Input
              value={ctx.getFieldValue('description')}
              onChange={(e) => ctx.setFieldValue('description', e.target.value)}
              onBlur={() => ctx.setFieldTouched('description', true)}
            />
            <form.ErrorMessage />
          </form.Field>

          {/* The submission state depends on `<form.Root onSubmit={...} />` */}
          {/* Its type has been defined upfront on `defineForm` */}
          {ctx.submission.isError && (
            <div>{ctx.submission.error.errorMessage}</div>
          )}
          {ctx.submission.isSuccess && (
            <div>{ctx.submission.data.successMessage}</div>
          )}

          {/* Show the error count after a failed submit attempt */}
          {!!ctx.hasErrors && !!ctx.tryToSubmitErrorCount && (
            <div>
              The form has {ctx.errorCount.all} error(s)&nbsp;
              {/* Add a button to go to the first error */}
              <button
                children="See the error"
                onClick={() => ctx.scrollToFirstTouchedError()}
              />
            </div>
          )}
          <button children="Submit" />
        </form.Form>
      )}
    </form.Root>
  )
}

Validation (synchronous)

💡 Why field based validation?

Because top level schema based validation strategies can quickly become cumbersome and often become a "lifecycle duplicate" of the form markup, the validation can only be performed at the Field level.

This design decision makes it easier to deal with multi-step forms and conditional fields: for a given Field the validation is done only if the Field is mounted

💡 When is the validation executed?

When the form values change, all fields are validated to make sure dependent fields are updated.

💡 When will the error message be displayed?

The ErrorMessage component shows the string returned by the validate function when the field is touched.

You can touch a field by using ctx.setFieldTouched(fieldName, true).

When the form is submitted, all fields are touched.

function MyTodoForm() {
  return (
    <form.Root initialValues={{ email: '', password: '' }} onSubmit={postTodo}>
      {(ctx) => (
        <form.Form>
          <form.Field
            name="title"
            validate={(title, formValues) => {
              if (title.length < 5) {
                return 'The title is too short'
              }
              if (title.length > formValues.description.length) {
                return 'The title should not be longer than the description'
              }
            }}>
            {/* ... */}
          </form.Field>

          <form.Field
            name="description"
            validate={(description, formValues) => {
              if (description.length < 20) {
                return 'The description is too short'
              }
              if (description.length < formValues.title.length) {
                return 'The description should be longer than the title'
              }
            }}>
            {/* ... */}
          </form.Field>

          {/* ... */}
        </form.Form>
      )}
    </form.Root>
  )
}

Validation (async)

Field async validation

Check is an email is already in use before signing up

'use client'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { BiCheck } from 'react-icons/bi'
import { MdDangerous } from 'react-icons/md'
import { defineForm } from 'crustack/form'
import { timeout } from 'crustack/utils'
import { useDebounceValue } from 'crustack/use-debounce-value'
import { Spinner } from './spinner'
import { validateEmailSync } from './validate'

// define the form
const form = defineForm<{ email: string; password: string }, unknown, unknown>()

export function SignupForm() {
  return (
    <form.Root
      initialValues={{ email: '', password: '' }}
      onSubmit={console.log}>
      {() => (
        <form.Form>
          <EmailField />
          <PasswordField />
          <button>Submit</button>
        </form.Form>
      )}
    </form.Root>
  )
}

function PasswordField() {
  // your password field here
}

function EmailField() {
  const { validateForm, ...ctx } = form.useFormCtx()
  const email = ctx.getFieldValue('email')
  const debounced = useDebounceValue(email, 500)
  /**
   * Using the synchronous email value instead of the debounced value
   * in order to avoid using stale cached results.
   * Using debounced.value in the queryKey will output the cached result
   * of the previous debounced.value while debounced.isPending.
   */
  const validateEmailQuery = useQuery({
    queryKey: ['email', email],
    // disable the query if there is a sync error, or the email is being debounced
    enabled: !debounced.isPending && !validateEmailSync(email),
    // Avoid throwing the validation response: tanstack-query doesn't cache errors
    queryFn: async () => {
      // touch the field to show the validation result
      ctx.setFieldTouched('email', true)
      await timeout(1000)
      if (email === 'valid@email.com') return ''
      return 'Email already exists'
    },
  })

  /**
   * Revalidate the form when the async validation is completed.
   * This will update all displayed error messages
   */
  useEffect(validateForm, [
    validateEmailQuery.data,
    validateEmailQuery.error,
    validateForm,
  ])

  const showError =
    // show the error state when the field is touched
    ctx.getFieldTouched('email') && getEmailErrorMessage(email)

  const showSuccess =
    // no pending state when there is an error
    !getEmailErrorMessage(email) &&
    // show the success state when the async query is successful
    validateEmailQuery.isSuccess

  const showPending =
    // no pending state when the sync validation fails
    !validateEmailSync(email) &&
    // otherwise show pending state
    (debounced.isPending || validateEmailQuery.isLoading)

  function getEmailErrorMessage(value: string) {
    return (
      // sync validation
      validateEmailSync(value) ||
      // async validation, since we don't throw the validation result
      // to best use the cache, we use the `data` property
      validateEmailQuery.data ||
      // async validation failed (eg. "Failed to fetch")
      (validateEmailQuery.isError &&
        'An unexpected error has occurred, please try again later.')
    )
  }

  return (
    <form.Field name="email" validate={getEmailErrorMessage}>
      <form.Label>Your email address</form.Label>

      <input
        value={email}
        onChange={(e) => ctx.setFieldValue('email', e.target.value)}
        onBlur={() => ctx.setFieldTouched('email', true)}
      />
      {showError ? (
        <MdDangerous />
      ) : showSuccess ? (
        <BiCheck />
      ) : showPending ? (
        // Don't submit the form when the validation is pending
        <form.PreventSubmit>
          <Spinner />
        </form.PreventSubmit>
      ) : null}

      <form.ErrorMessage />
    </form.Field>
  )
}

Form async validation

TODO

Field arrays

TODO

API

⚫ defineForm

Expects no argument but the form types. Returns the elements of the form, 100% type-safe.

// define a form that has:
// - a "title" field that has a string value
// - a "description" field that has a string value
type FormValues = { title: string; description: string }
// The type of the submission error, raised in `onSubmit`
type SubmissionError = { errorMessage: string }
// The type of the submission data, returned by `onSubmit`
type SubmissionData = { successMessage: string }

const form = defineForm<FormValues, SubmissionError, SubmissionData>()

⚫ form.Root

Contains all the parts of the form.

PropDefaulttypedescription
initialValues(required)FormValuesThe initial values of the form
children(required)(ctx: Ctx) => ReactNodeThe children receive the form context via renderProps
onSubmit(required)(values: FormValues, ctx: Ctx) => MaybePromise<SubmissionData>Must return the SubmissionData (under a promise or not), or throw the SubmissionError. The result is then accessible under ctx.submission.data and ctx.submission.error
onChange-(values: FormValues, prev: FormValues, ctx: Ctx) => voidCall ctx.tryToSubmit() here if you want to submit on change
onSubmitSuccess-(data: SubmissionData) => voidCalled after onSubmit resolves
onSubmitError-(error: SubmissionError) => voidCalled after onSubmit throws or rejects
onSubmitSettled-() => voidCalled after onSubmitSuccess and onSubmitError
onTryToSubmitError-(ctx: Ctx) => voidCalled after tryToSubmit fails, when there are validation errors or PreventSubmit is rendered

⚫ form.Form

The same as a regular <form> with a few internals.

One <form.Root> can contain several <form.Form>.

Prefer the onSubmit of <form.Root>

⚫ form.Field

Contains the Description, Label, ErrorMessage and the control (eg <input />).

Does not render any html tag.

PropDefaulttypedescription
name(required)FieldNameThe field name is a key of FormValues or a key with index access for arrays (eg 'todo' or 'todo.0')
disabledfalsebooleanInstead of passing the disabled prop to the control, you can pass it to the Field. When a field is disabled, the control, Description and Label receive the data-disabled attribute. The control also receives the aria-disabled attribute
validate-(value: FieldValue, values: FormValues) => string \| false \| null \| undefinedThe field validation function that is executed everytime the form state changes. Return a the error message when the field is invalid, otherwise a falsy value. When a field is invalid, the control, Description and Label receive the data-invalid attribute. The control also receives the aria-invalid attribute
children-ReactNode \| ((controlProps: ControlProps) => ReactElement)The children can use the renderProps pattern. Useful when you don't want to use useFieldControlAccessibilityProps. Usage: <Field>{(controlProps) => <input {...controlProps} />}</Field>

⚫ form.FieldArray

Same as Field but renders an html element to handle labels, descriptions and error message.

A FieldArray is touched when at least one of it's descendant field it touched

PropDefaulttypedescription
as'div'stringThe html tag to render
name(required)FieldNameThe field name is a key of FormValues or a key with index access for arrays (eg 'todo' or 'todo.0')
disabledfalsebooleanPass true to disable all descendant fields and controls. Works the same as for Field
validate-(value: FieldValue, values: FormValues) => string \| false \| null \| undefinedThe same as for Field
children-ReactNodeThe children...

⚫ form.Label

The label(s) associated with the control. Should be used within a Field or FieldArray.

PropDefaulttypedescription
as'label'stringThe html tag to render for the Label.

⚫ form.Description

The description(s) associated with the control. Should be used within a Field or FieldArray.

PropDefaulttypedescription
as'div'stringThe html tag to render for the description.

⚫ form.ErrorMessage

Displays the validation error message of the closest Field or FieldArray element.

PropDefaulttypedescription
as'div'stringThe html tag to render for the description.
forceMountfalsebooleanRender the html element even if there is no error to show.
render(message) => message(error: string \| undefined) => ReactNodeCustom render for the error message

⚫ form.PreventSubmit

The form submission is considered invalid when this component is rendered. Useful with async validation.

PropDefaulttypedescription
children-ReactNodeThis component accepts children. eg {isLoading && <PreventSubmit><Spinner /></PreventSubmit>}

⚫ form.useFormCtx

Access the type-safe form context.

Returnstypedescription
errorCount.allnumberThe count of all touched and not touched errors
errorCount.touchednumberThe count of all touched errors
getFieldError(field:FieldName) => string \| undefinedGet a field's error message
getFieldTouched(field:FieldName) => boolean \| boolean[] \| undefinedGet a field's touch state. boolean \| undefined for normal fields, boolean[] \| undefined for field arrays
getFieldValue(field:FieldName) => FieldValueGet a field's current value
hasErrorsbooleanTells if the form has any error
scrollToFirstTouchedError(options: ScrollIntoViewOptions & { scrollMarginTop?: string \| number }) => voidScroll to the first data-invalid element.
setFieldTouched(field: FieldName, value: boolean \| boolean[] \| undefined) => voidSet a field's touched state. Can be boolean \| undefined for normal fields, boolean[] \| undefined for field arrays
setFieldValue(field: FieldName, updater: FieldValue \| ((values: FormValues) => FieldValue)Set a field's value.
setValues(updater: Partial<FormValues> \| ((prev: FormValues) => Partial<FormValues>)) => voidSet the form values. Partial FormValues are shallowly merged.
setTouched(updater: Partial<FormTouched> \| ((prev: FormTouched) => Partial<FormTouched>), ) => voidSet the form touched state. Partial FormTouched are shallowly merged.
submission.errorSubmissionErrorWhatever is thrown or rejected the Root onSubmit handler
submission.isErrorbooleantrue when there is a submission.error
submission.isLoadingbooleantrue the Root onSubmit handler is a pending async function
submission.isSuccessbooleantrue the Root onSubmit handler either resolved or returned without throwing
submission.dataSubmissionDataThe data returned or resolved by the Root onSubmit handler
submission.valuesFormValuesThe form values used to trigger the last Root onSubmit call
tryToSubmit() => voidManually try to submit the form. The submission will be prevented if hasErrors is true or if the PreventSubmit component is mounted
tryToSubmitErrorCountnumberThe number of times the user tried to submit with a validation error
validateForm() => voidValidate the entire form on demand based on the current values
valuesFormValuesThe current FormValues. Prefer getFieldValue in most cases.

➡ Utils

✔ cn

className utility that removes false | null | undefined | 0 from the output string.

import { cn } from 'crustack/utils'

const MyComponent = () => (
  <div
    className={cn(
      className, // removes undefined className
      isOpen && 'bg-gray-200', // removes falsy values
      'flex items-center',
    )}>
    ...
  </div>
)

✔ toPx

Make sure numbers are transformed to pixels

import { toPx } from 'crustack/utils'

const r1 = toPx(16) // "16px"
const r2 = toPx('1rem') // "1rem"
const r3 = toPx(undefined) // undefined

✔ entriesOf

Same as Object.entries but with proper types

import { entriesOf } from 'crustack/utils'

const object = { a: 1, b: '' }

const entries = entriesOf(object)
// Array<['a', number] | ['b', string]>

const entries = Object.entries(object)
// Array<[string, string | number]>

✔ keysOf

Same as Object.keys but with proper types

import { keysOf } from 'crustack/utils'

const object = { a: 1, b: '' }

const keys = keysOf(object)
// Array<'a' | 'b'>

const keys = Object.keys(object)
// Array<string>

✔ valuesOf

Same as Object.keys but works with unions

import { valuesOf } from 'crustack/utils'

const object: { a: number; b: string } | { c: boolean } = { c: true }

const values = valuesOf(object)
// Array<string | number> | Array<boolean>

const values = Object.values(object)
// Array<any>

✔ timeout

A sleep function that never rejects.

import { timeout } from 'crustack/timeout'

timeout(500).then(() => console.log('500ms have elapsed'))
// or
await timeout(500)
console.log('500ms have elapsed')

✔ queueMacrotask

Same as queueMicrotask, but for macrotacks

import { queueMacrotask } from 'crustack/utils'

queueMacrotask(() => console.log('Macrotask done'))

// queueMacrotack be executed will after these
Promise.resolve().then(() => console.log('Promise done'))
queueMicrotask(() => console.log('Microtask done'))

✔ useEventHandler

Gives a stable identity to a function

import { useEventHandler } from 'crustack/use-event-handler'

type Props = {
  onClick: () => void
  count: number
}

function MyComponent(props: Props) {
  /**
   * If not memoized, `props.onClick` keeps changing.
   * `useEventHandler` fixes it, `onClick` has a stable identity
   */
  const onClick = useEventHandler(props.onClick)

  useEffect(() => {
    // only runs once
  }, [onClick])

  useEffect(() => {
    // only runs when props.count changes
  }, [props.count, onClick])

  return <div>...</div>
}

✔ useInvariantContext

Same as useContext but makes sure the context is non-falsy

import { useInvariantContext } from 'crustack/use-invariant-context'

const MyContext= createContext<null | TContext>(null)

...

function useMyContext(){
  return useInvariantContext(MyContext, 'Context not found')
}

// Instead of

function useMyContext(){
  const ctx = useContext(MyContext)
  if(!ctx) throw new Error('Context not found')
  return ctx
}

✔ useIsomorphicLayoutEffect

Avoid hydration errors with SSR.

Uses useLayoutEffect on the client, useEffect on the server (which is ignored).

Will probably not be necessary with React 19

import { useIsomorphicLayoutEffect } from 'crustack/use-isomorphic-layout-effect'

useIsomorphicLayoutEffect(() => {
  console.log('useLayoutEffect on the client')
}, [])

✔ useLatestRef

Keeps a ref in sync with whatever is passed to it. Useful to bypass dependency arrays.

import { useLatestRef } from 'crustack/use-latest-ref'

type Props = {
  config: Record<string, any>
  count: number
}

function MyComponent(props: Props) {
  // configRef is always up to date
  // since it is a ref it has a stable identity
  const configRef = useLatestRef(props.config)

  useEffect(() => {
    // only runs once
  }, [configRef])

  useEffect(() => {
    // only runs when props.count changes
  }, [props.count, configRef])

  return <div>...</div>
}

✔ useRefLazyInit

Just like useRef but with a functional initializer. Useful when the initializer is expensive since it is executed on first render only.

import { useRefLazyInit } from 'crustack/use-ref-lazy-init'

// the initializer is executed once
const ref = useRefLazyInit(() => initialize())

// the initializer is executed on every render
const ref = useRef(initialize())

✔ useDomId

Same as useId, but safe to use in the DOM.

import { useDomId } from 'crustack/use-dom-id'

function MyComponent() {
  const id = useDomId()
  return <div id={id}>...</div>
}

✔ useIsMounted

Once true no hydration error can occur

import { useIsMounted } from 'crustack/use-is-mounted'

const isMounted = useIsMounted()

✔ useMergeRefs

Merge all types of refs, returns a callback ref.

import { useMergeRefs } from 'crustack/use-merge-refs'

// ref is a callback ref
const ref = useMergeRefs(forwardedRef, internalRef)

✔ useInterval

setInterval, reactified.

Pause the interval by setting the delay to null | false | undefined

import { useInterval } from 'crustack/use-interval'

// executes after 1000ms, every 1000ms
useInterval(() => console.log('Hello'), 1000)
// executes immiediately and every 1000ms
useInterval(() => console.log('Hello'), 1000, { immediate: true })
// never executes
useInterval(() => console.log('Hello'), null, { immediate: true })

✔ useDebounceFn

debounce function, reactified

The debounce call is automatically cancelled when the component unmounts

import { useDebounceFn } from 'crustack/use-debounce-fn'

function MyComponent() {
  const { debounce, cancel, isPending } = useDebounceFn()

  return (
    <button onClick={() => debounce(() => fetchData(), 500)}>
      Fetch some data
    </button>
  )
}

✔ useDebounceValue

Get a deferred version of the value

import { useDebounceValue } from 'crustack/use-debounce-value'

function MyComponent() {
  const [value, setValue] = useState('')

  const debounced = useDebounceValue(value, 500)

  // `debounced.value` will change 500ms after `value`
  console.log(debounced.value)
  // `debounced.isPending` is true when `debounced.value` differs form `value`
  console.log(debounced.isPending)

  return <input onChange={(e) => setValue(e.target.value)} value={value} />
}

✔ useCountdown

Performant countdown utility that does not produce rerenders

Start by placing CountdownProvider in your App Providers. CountdownProvider makes sure all countdown ticks are synchronized.

The default tickInterval is 1000ms

'use client'
import { CountdownProvider } from 'crustack/use-countdown'

function Providers() {
  return <CountdownProvider tickInterval={100}>...</CountdownProvider>
}

Use the countdown

'use client'
import { format } from './utils'

function NewYearCountdown() {
  const initialRemainingMs = useCountdown({
    to: '01-01-2025',
    onTick: setRemainingMs,
    onDone: () => console.log('🎉'),
  })

  const [remainingMs, setRemainingMs] = useState(initialRemainingMs)

  return <div>{format(remainingMs)}</div>
}

onTick is last called when the remainingTime reaches 0.

onDone is called when the remainingTime reaches 0.

to can be passed null | false | undefined to pause the countdown

// the countdown ticks only when `someCondition` is truthy
useCountdown({
  to: someCondition && '01-01-2025',
  onTick: console.log,
})

format is used to format the remaining time.

const initialRemainingSeconds  = useCountdown({
  to: "12-31-2030",
  format: (ms) => Math.ceil(ms / 1000)
  onTick: (sec) => setRemainingSeconds(sec),
})

➡ Types

✔ EntryOf

import { EntryOf } from 'crustack/types'

type MyObject = {
  a: number
  b: string
}

type Entry = EntryOf<MyObject>
// ['a', number] | ['b', string]

✔ KeyOf

While keyof only takes the keys of the intersection of a union, KeyOf takes all keys of a union

import { KeyOf } from 'crustack/types'

type MyObject =
  | {
      a: number
      b: string
    }
  | {
      b: string
      c?: boolean
    }

type T = KeyOf<MyObject>
// 'a' | 'b' | 'c'

type U = keyof MyObject
// 'b'

✔ ValueOf

Same as KeyOf, but for values

import { ValueOf } from 'crustack/types'

type MyObject = {
  a: number
  b: string
}

type T = ValueOf<MyObject>
// number | string

✔ DistributiveOmit

Same as Omit, but works with unions

import { DistributiveOmit } from 'crustack/types'

// works as expected:
// Omit<{ a: string, b: string }, "c"> | Omit<{ b: string, c: string }, "c">
type T = DistributiveOmit<
  { a: string; b: string } | { b: string; c: string },
  'c'
>

// forgets both 'a' and 'c':
// { b: string }
type U = Omit<{ a: string; b: string } | { b: string; c: string }, 'c'>

✔ Prettify

Makes a type more readable.

import { Prettify } from 'crustack/types'

type T = Prettify<SomeType>

✔ UnionToIntersection

Transforms a Union type to an Intersection type

import { UnionToIntersection } from 'crustack/types'

type I = UnionToIntersection<{ a: string } | { b: number }>
// I = { a: string } & { b: number }

✔ MaybePromise

Describe a type that might be a Promise

import { MaybePromise } from 'crustack/types'

type T = MaybePromise<{ a: string }>
// T = { a: string } | Promise<{ a: string }>

✔ MaybeArray

Describe a type that might be an Array

import { MaybeArray } from 'crustack/types'

type T = MaybeArray<{ a: string }>
// T = { a: string } | Array<{ a: string }>
0.6.14

5 months ago

0.6.13

5 months ago

0.6.12

5 months ago

0.6.11

5 months ago

0.6.10

5 months ago

0.6.9

6 months ago

0.6.8

6 months ago

0.6.8-beta.0

6 months ago

0.6.8-beta.1

6 months ago

0.6.7

6 months ago

0.6.6

6 months ago

0.6.5

7 months ago

0.6.4

7 months ago

0.6.3

7 months ago

0.6.2

7 months ago

0.6.1

7 months ago

0.6.0

7 months ago

0.5.3

7 months ago

0.5.2

7 months ago

0.5.1

7 months ago

0.6.0-beta.0

7 months ago

0.5.0

8 months ago

0.4.1

8 months ago

0.3.4-beta.0

9 months ago

0.4.0-beta.0

8 months ago

0.3.6

9 months ago

0.3.5

9 months ago

0.3.7

9 months ago

0.3.4

9 months ago

0.4.0

8 months ago

0.3.5-beta.0

9 months ago

0.3.0-beta.2

9 months ago

0.3.0

9 months ago

0.3.0-beta.0

9 months ago

0.3.3-beta.0

9 months ago

0.3.0-beta.1

9 months ago

0.3.2

9 months ago

0.3.3

9 months ago

0.3.0-beta-0

9 months ago

0.3.1-beta.1

9 months ago

0.3.1-beta.0

9 months ago

0.2.30

9 months ago

0.2.30-beta.1

9 months ago

0.2.30-beta.0

9 months ago

0.2.28-beta.0

9 months ago

0.2.29

9 months ago

0.2.28

9 months ago

0.2.27

9 months ago

0.2.26

9 months ago

0.2.25

9 months ago

0.2.24

9 months ago

0.2.23

9 months ago

0.2.22

9 months ago

0.2.21

9 months ago

0.2.20

9 months ago

0.2.19

9 months ago

0.1.0-beta.3

10 months ago

0.1.0-beta.2

10 months ago

0.1.0-beta.5

10 months ago

0.1.0-beta.4

10 months ago

0.1.0-beta.1

10 months ago

0.1.0-beta.0

10 months ago

0.1.0-beta.10

10 months ago

0.1.0-beta.12

10 months ago

0.1.0-beta.11

10 months ago

0.2.18

9 months ago

0.2.17

9 months ago

0.1.0-beta.14

10 months ago

0.1.0-beta.13

10 months ago

0.1.0-beta.6

10 months ago

0.1.0-beta.9

10 months ago

0.1.0-beta.15

10 months ago

0.1.0-beta.8

10 months ago

0.0.73

10 months ago

0.0.70-beta.0

10 months ago

0.0.75-beta.0

10 months ago

0.0.74

10 months ago

0.2.16

9 months ago

0.2.15

9 months ago

0.2.14

9 months ago

0.2.13

9 months ago

0.2.12

9 months ago

0.2.11

9 months ago

0.0.70

10 months ago

0.2.10

9 months ago

0.0.71

10 months ago

0.0.72

10 months ago

0.0.64

11 months ago

0.0.65

11 months ago

0.0.66

11 months ago

0.0.67

11 months ago

0.0.68

11 months ago

0.0.69

11 months ago

0.2.15-beta.0

9 months ago

0.2.15-beta.2

9 months ago

0.1.2-beta.2

10 months ago

0.1.2-beta.3

10 months ago

0.1.2-beta.0

10 months ago

0.1.2-beta.1

10 months ago

0.1.2-beta.11

10 months ago

0.1.2-beta.6

10 months ago

0.1.2-beta.7

10 months ago

0.1.2-beta.4

10 months ago

0.1.2-beta.5

10 months ago

0.1.2-beta.10

10 months ago

0.1.2-beta.8

10 months ago

0.1.2-beta.9

10 months ago

0.1.0

10 months ago

0.1.2

10 months ago

0.1.1

10 months ago

0.1.3

10 months ago

0.1.1-beta.0

10 months ago

0.2.9-beta.1

9 months ago

0.2.5-beta.1

10 months ago

0.2.9-beta.0

10 months ago

0.2.5-beta.0

10 months ago

0.2.9-beta.3

9 months ago

0.2.9-beta.2

9 months ago

0.2.3-beta.0

10 months ago

0.2.3-beta.1

10 months ago

0.2.3-beta.2

10 months ago

0.2.0-beta.2

10 months ago

0.2.0-beta.1

10 months ago

0.2.0-beta.0

10 months ago

0.2.0-beta.5

10 months ago

0.2.0-beta.4

10 months ago

0.2.0-beta.3

10 months ago

0.2.1

10 months ago

0.2.0

10 months ago

0.2.7

10 months ago

0.2.6

10 months ago

0.2.9

9 months ago

0.2.8

10 months ago

0.2.3

10 months ago

0.2.2

10 months ago

0.2.5

10 months ago

0.2.4

10 months ago

0.0.62

11 months ago

0.0.63

11 months ago

0.0.60

11 months ago

0.0.61

11 months ago

0.0.59

11 months ago

0.0.55

11 months ago

0.0.56

11 months ago

0.0.57

11 months ago

0.0.58

11 months ago

0.0.43

11 months ago

0.0.44

11 months ago

0.0.45

11 months ago

0.0.46

11 months ago

0.0.51

11 months ago

0.0.52

11 months ago

0.0.53

11 months ago

0.0.54

11 months ago

0.0.50

11 months ago

0.0.45-beta.1

11 months ago

0.0.45-beta.0

11 months ago

0.0.45-beta.3

11 months ago

0.0.45-beta.2

11 months ago

0.0.43-beta.0

11 months ago

0.0.48

11 months ago

0.0.49

11 months ago

0.0.40

12 months ago

0.0.41

12 months ago

0.0.42

12 months ago

0.0.37

12 months ago

0.0.38

12 months ago

0.0.39

12 months ago

0.0.30

12 months ago

0.0.31

12 months ago

0.0.32

12 months ago

0.0.33

12 months ago

0.0.34

12 months ago

0.0.35

12 months ago

0.0.36

12 months ago

0.0.28

12 months ago

0.0.33-beta.0

12 months ago

0.0.29

12 months ago

0.0.33-beta.1

12 months ago

0.0.33-beta.3

12 months ago

0.0.33-beta.4

12 months ago

0.0.27

1 year ago

0.0.26

1 year ago

0.0.25

1 year ago

0.0.24

1 year ago

0.0.23

1 year ago

0.0.22

1 year ago

0.0.21

1 year ago

0.0.20

1 year ago

0.0.19

1 year ago

0.0.18

1 year ago

0.0.17

1 year ago

0.0.16

1 year ago

0.0.15

1 year ago

0.0.14

1 year ago

0.0.13

1 year ago

0.0.12

1 year ago

0.0.11

1 year ago

0.0.10

1 year ago

0.0.9

1 year ago

0.0.8

1 year ago

0.0.7

1 year ago

0.0.6

1 year ago

0.0.5

1 year ago

0.0.4

1 year ago

0.0.3

1 year ago

0.0.2

1 year ago

0.0.1

1 year ago

0.0.1-beta.12

1 year ago

0.0.1-beta.11

1 year ago

0.0.1-beta.10

1 year ago

0.0.1-beta.9

1 year ago

0.0.1-beta.8

1 year ago

0.0.1-beta.7

1 year ago

0.0.1-beta.6

1 year ago

0.0.1-beta.5

1 year ago

0.0.1-beta.4

1 year ago

0.0.1-beta.3

1 year ago

0.0.1-beta.2

1 year ago

0.0.1-beta.1

1 year ago