crustack v0.6.14
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>
)
}
Prop | Default | description |
---|---|---|
isOpen | (required) | Control the open state |
onClose | (required) | Called when the dialog is closed |
mandatory | false | Prevent closing the dialog on "Esc" or backdrop click |
preventAutofocus | true | Prevents 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)
{/* 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.
Prop | Default | type | description |
---|---|---|---|
initialValues | (required) | FormValues | The initial values of the form |
children | (required) | (ctx: Ctx) => ReactNode | The 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) => void | Call ctx.tryToSubmit() here if you want to submit on change |
onSubmitSuccess | - | (data: SubmissionData) => void | Called after onSubmit resolves |
onSubmitError | - | (error: SubmissionError) => void | Called after onSubmit throws or rejects |
onSubmitSettled | - | () => void | Called after onSubmitSuccess and onSubmitError |
onTryToSubmitError | - | (ctx: Ctx) => void | Called 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.
Prop | Default | type | description |
---|---|---|---|
name | (required) | FieldName | The field name is a key of FormValues or a key with index access for arrays (eg 'todo' or 'todo.0' ) |
disabled | false | boolean | Instead 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 \| undefined | The 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
Prop | Default | type | description |
---|---|---|---|
as | 'div' | string | The html tag to render |
name | (required) | FieldName | The field name is a key of FormValues or a key with index access for arrays (eg 'todo' or 'todo.0' ) |
disabled | false | boolean | Pass true to disable all descendant fields and controls. Works the same as for Field |
validate | - | (value: FieldValue, values: FormValues) => string \| false \| null \| undefined | The same as for Field |
children | - | ReactNode | The children... |
⚫ form.Label
The label(s) associated with the control. Should be used within a Field
or FieldArray
.
Prop | Default | type | description |
---|---|---|---|
as | 'label' | string | The html tag to render for the Label. |
⚫ form.Description
The description(s) associated with the control. Should be used within a Field
or FieldArray
.
Prop | Default | type | description |
---|---|---|---|
as | 'div' | string | The html tag to render for the description. |
⚫ form.ErrorMessage
Displays the validation error message of the closest Field
or FieldArray
element.
Prop | Default | type | description |
---|---|---|---|
as | 'div' | string | The html tag to render for the description. |
forceMount | false | boolean | Render the html element even if there is no error to show. |
render | (message) => message | (error: string \| undefined) => ReactNode | Custom render for the error message |
⚫ form.PreventSubmit
The form submission is considered invalid when this component is rendered. Useful with async validation.
Prop | Default | type | description |
---|---|---|---|
children | - | ReactNode | This component accepts children. eg {isLoading && <PreventSubmit><Spinner /></PreventSubmit>} |
⚫ form.useFormCtx
Access the type-safe form context.
Returns | type | description |
---|---|---|
errorCount.all | number | The count of all touched and not touched errors |
errorCount.touched | number | The count of all touched errors |
getFieldError | (field:FieldName) => string \| undefined | Get a field's error message |
getFieldTouched | (field:FieldName) => boolean \| boolean[] \| undefined | Get a field's touch state. boolean \| undefined for normal fields, boolean[] \| undefined for field arrays |
getFieldValue | (field:FieldName) => FieldValue | Get a field's current value |
hasErrors | boolean | Tells if the form has any error |
scrollToFirstTouchedError | (options: ScrollIntoViewOptions & { scrollMarginTop?: string \| number }) => void | Scroll to the first data-invalid element. |
setFieldTouched | (field: FieldName, value: boolean \| boolean[] \| undefined) => void | Set 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>)) => void | Set the form values. Partial FormValues are shallowly merged. |
setTouched | (updater: Partial<FormTouched> \| ((prev: FormTouched) => Partial<FormTouched>), ) => void | Set the form touched state. Partial FormTouched are shallowly merged. |
submission.error | SubmissionError | Whatever is thrown or rejected the Root onSubmit handler |
submission.isError | boolean | true when there is a submission.error |
submission.isLoading | boolean | true the Root onSubmit handler is a pending async function |
submission.isSuccess | boolean | true the Root onSubmit handler either resolved or returned without throwing |
submission.data | SubmissionData | The data returned or resolved by the Root onSubmit handler |
submission.values | FormValues | The form values used to trigger the last Root onSubmit call |
tryToSubmit | () => void | Manually try to submit the form. The submission will be prevented if hasErrors is true or if the PreventSubmit component is mounted |
tryToSubmitErrorCount | number | The number of times the user tried to submit with a validation error |
validateForm | () => void | Validate the entire form on demand based on the current values |
values | FormValues | The 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 }>
5 months ago
5 months ago
5 months ago
5 months ago
5 months ago
6 months ago
6 months ago
6 months ago
6 months ago
6 months ago
6 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
7 months ago
8 months ago
8 months ago
9 months ago
8 months ago
9 months ago
9 months ago
9 months ago
9 months ago
8 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
9 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
10 months ago
9 months ago
10 months ago
10 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
9 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
9 months ago
10 months ago
10 months ago
10 months ago
9 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago