跳至正文
组件

Phone Input手机号输入

中国大陆手机号输入:边输边按 3-4-4 分隔且光标不乱跳,粘贴、自动填充带 +86 或横线的号码自动整理,失焦校验号段;条内可放带倒计时的「获取验证码」。值是 11 位纯数字。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/phone-input.json
会连带安装 shadcn 组件 button。已存在的文件会询问是否覆盖。

用法

components/login-form.tsx
import {
  PhoneInput,
  PhoneInputSendButton,
} from "@/components/ui/phone-input/phone-input"

export function LoginForm() {
  return (
    <PhoneInput
      name="phone"
      label="手机号"
      required
      action={<PhoneInputSendButton onSend={() => sendCode()} />}
    />
  )
}

value、defaultValue、onValueChange 的值都是 11 位纯数字字符串("13800138000"),没填是空串;界面上看到的 138 0013 8000 只是显示。设了 name,表单提交的也是纯数字。

输入整理

  • 只收数字,全角数字转半角,其他字符丢掉;最多 11 位。
  • 粘贴或自动填充带区号、横线、空格的号码(+86 138-0013-8000、0086 13800138000)会去掉区号和符号,剩下 11 位。
  • 增删时光标停在同一个数字后面;光标紧贴分隔空格时按退格,删的是旁边的数字,不会删了空格又长回来。

校验

号码要是 11 位、以 13–19 开头。敲字时不打扰,失焦后不合格才提示「请输入正确的手机号」;required 时没填会拦住表单提交。自己的业务校验(比如「该号码已注册」)判断后传 error,和组件自带的提示在同一个位置。

获取验证码

action 放在条内右侧,通常放 PhoneInputSendButton:

  • 号码不合格时自动置灰;点了先执行 onSend,返回 Promise 时显示「发送中…」,完成后倒计时「56 秒后重发」,结束变成「重新获取」。
  • onSend 抛错时不进入倒计时,可以马上重试;错误提示由你自己处理。
  • 剩余秒数按截止时间算,页面切到后台计时器变慢也不会多等。
  • 单独使用(比如邮箱输入旁边)时传 variant="button",外观和高度与按钮一致;需要完全自己画按钮时用 useSendCode(onSend, cooldown),它返回 { state, remaining, send }。

手机上

  • type="tel" 加 inputMode="numeric":iOS 弹纯数字键盘,自动填充也认得出这是电话号码(autocomplete="tel-national")。
  • 触屏设备上方条各加高一档(36 / 40 / 44px),字号不小于 16px,聚焦时页面不会被放大。

示例

手机上的体验

一台按真机尺寸画的「手机」里的验证码登录页:填号码 → 获取验证码 → 输入 6 位(演示用 246810)→ 登录。验证码格子直接用 Base UI 的 OTPField(shadcn 的 input-otp 同理),样式照方条写,见示例源码。

正在加载…

尺寸

三档高度和方条开关、金额输入一致(28 / 32 / 36px,触屏上 36 / 40 / 44px),带不带「获取验证码」都对得齐。最后一行是单独使用的发送按钮。

正在加载…

表单与校验

试试输入 128 开头的号码,或只填一半再点别处。

正在加载…

API

className 作用于根元素,其余未列出的属性(id、required、placeholder、autoFocus、onFocus、onBlur……)交给 <input>;ref 指向 <input>。name 例外:它加在一个隐藏输入上,提交纯数字。

PhoneInputSendButton

工具函数

formatPhone("13800138000") // "138 0013 8000"
maskPhone("13800138000") // "138 **** 8000",第二个参数换打码符号
isValidPhone("13800138000") // true:11 位、13–19 开头
useSendCode(onSend, 60) // { state: "idle" | "sending" | "counting" | "again", remaining, send }

labels

type PhoneInputLabels = {
  placeholder: string // "请输入手机号"
  invalid: string // "请输入正确的手机号"
  required: string // "请输入手机号"
  send: string // "获取验证码"
  sending: string // "发送中…"
  countdown: string // "{seconds} 秒后重发"
  resend: string // "重新获取"
}

PhoneInput 和 PhoneInputSendButton 各自接受 labels。

data-slot

元素data-slot
根元素phone-input
方条外框phone-input-control
条内标签phone-input-label
输入框phone-input-input
获取验证码按钮phone-input-send
错误提示phone-input-error
提交用的隐藏输入phone-input-value

根元素带 data-size,出错时带 data-invalid,禁用带 data-disabled;发送按钮带 data-state(idle / sending / counting / again)。

可访问性

  • label 写在方条内左侧,通过 htmlFor 与输入框关联,点标签或条内空白都会聚焦输入框;没有可见标签时传 aria-label。
  • 错误提示通过 aria-describedby 关联,输入框同时带 aria-invalid;错误态不只靠红色,还有文字。
  • 发送按钮是原生 button,倒计时期间 disabled;秒数每秒都在变,不用 aria-live 逐秒播报,读屏聚焦时会念出当前文字。

注意事项

  • 触屏设备上方条加高一档(36 / 40 / 44px)。并排的按钮要一起加高才对得齐:本站的 Button 已经在三档尺寸里加了 pointer-coarse:h-9 / h-10 / h-11;如果你用的是 shadcn 默认的按钮,在 button.tsx 的 sm、default、lg 里补上这三个类即可。
  • 只处理中国大陆手机号(11 位)。需要选国家 / 地区的国际号码,请用专门的国际号码库。
  • 受控模式下记得同时处理 onValueChange;外部传入的值会先去掉非数字再显示。
  • 非受控的表单 reset 会回到 defaultValue;受控的由外部自己重置。
  • 撤销(⌘Z):整理会重写输入框内容,浏览器的撤销栈不再可靠,这是实时格式化输入的通病。
  • 出错时传 aria-invalid 或 error,方条变红,和其他表单控件的错误态一致。

源码

components/ui/phone-input/phone-input.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"

import { DEFAULT_LABELS, PHONE_LENGTH } from "./config"
import {
  PhoneInputContext,
  PhoneInputSendButton,
  phoneInputControlVariants,
  useSendCode,
} from "./phone-input-parts"
import type { PhoneInputProps } from "./types"
import {
  digitsOf,
  formatPhone,
  isValidPhone,
  maskPhone,
  sanitizePhone,
} from "./utils"

/**
 * 中国大陆手机号输入:边输边按 3-4-4 分隔,光标不乱跳;粘贴、自动填充带区号或横线的号码也能整理。
 * 值是 11 位纯数字,表单提交的也是它。条内右侧可以放「获取验证码」。
 */
function PhoneInput({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  label,
  size = "default",
  action,
  error,
  labels: labelsProp,
  className,
  id: idProp,
  name,
  form,
  required,
  disabled,
  readOnly,
  placeholder,
  "aria-invalid": ariaInvalid,
  "aria-describedby": describedByProp,
  onFocus,
  onBlur,
  ref,
  ...props
}: PhoneInputProps) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp }
  const autoId = React.useId()
  const id = idProp ?? autoId
  const errorId = `${id}-error`
  const inputRef = React.useRef<HTMLInputElement>(null)
  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)

  const [value, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: digitsOf(defaultValue).slice(0, PHONE_LENGTH),
    onChange: onValueChange,
  })
  const text = formatPhone(value)
  const [touched, setTouched] = React.useState(false)

  // 整理后要恢复的光标位置;每次都是新对象,保证文本没变(敲了非法字符)时也会重设光标
  const [caret, setCaret] = React.useState<{ position: number } | null>(null)
  React.useLayoutEffect(() => {
    const input = inputRef.current
    if (caret && input && document.activeElement === input)
      input.setSelectionRange(caret.position, caret.position)
  }, [caret])

  const commit = (raw: string, rawCaret: number) => {
    const next = sanitizePhone(raw, rawCaret)
    setValue(next.digits)
    setCaret({ position: next.caret })
  }

  const valid = isValidPhone(value)
  const ownMessage =
    value === ""
      ? required
        ? labels.required
        : ""
      : valid
        ? ""
        : labels.invalid
  // 浏览器的表单校验也拦住:号码不完整时提交会被挡下
  React.useEffect(() => {
    inputRef.current?.setCustomValidity(ownMessage)
  }, [ownMessage])

  const errorMessage = error || (touched && value !== "" ? ownMessage : "")
  const invalid =
    Boolean(errorMessage) || ariaInvalid === true || ariaInvalid === "true"
  const describedBy =
    [describedByProp, errorMessage ? errorId : undefined]
      .filter(Boolean)
      .join(" ") || undefined

  // 非受控时表单重置回到初始值;reset 事件在表单清空之前触发,等一帧再覆盖
  const controlled = valueProp !== undefined
  const resetRef = React.useRef(() => {})
  React.useEffect(() => {
    resetRef.current = () => {
      setValue(digitsOf(defaultValue).slice(0, PHONE_LENGTH))
      setTouched(false)
    }
  })
  React.useEffect(() => {
    const element = inputRef.current?.form
    if (!element || controlled) return
    const reset = () => window.setTimeout(() => resetRef.current())
    element.addEventListener("reset", reset)
    return () => element.removeEventListener("reset", reset)
  }, [controlled])

  return (
    <div
      data-slot="phone-input"
      data-size={size}
      data-invalid={invalid ? "" : undefined}
      data-disabled={disabled ? "" : undefined}
      // 容器查询:窄于 20rem 时缩小间距和按钮内边距,窄于 17rem 时再收起条内标签(读屏仍可读),号码不被挤掉
      className={cn(
        "@container/phone-input flex w-full flex-col gap-1.5",
        className,
      )}
    >
      <div
        data-slot="phone-input-control"
        className={cn(
          phoneInputControlVariants({ size }),
          "@max-[20rem]/phone-input:gap-2",
          invalid
            ? "border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40"
            : "border-edge focus-within:ring-3 focus-within:ring-ring/50 hover:bg-muted",
          disabled && "cursor-not-allowed opacity-50",
          readOnly && "bg-muted/30",
          // 条内有按钮时右边不留内边距,按钮自己贴边
          action && "overflow-hidden",
        )}
        // 点到条内空白处(标签与号码之间)也聚焦输入框
        onMouseDown={(event) => {
          if (event.target === event.currentTarget) {
            event.preventDefault()
            inputRef.current?.focus()
          }
        }}
      >
        {label && (
          <label
            data-slot="phone-input-label"
            htmlFor={id}
            className="shrink-0 text-muted-foreground max-md:text-sm @max-[17rem]/phone-input:sr-only"
          >
            {label}
          </label>
        )}
        <input
          {...props}
          ref={inputRef}
          id={id}
          data-slot="phone-input-input"
          // tel + numeric:iOS 弹纯数字键盘,自动填充也认得这是电话号码
          type="tel"
          inputMode="numeric"
          autoComplete="tel-national"
          spellCheck={false}
          value={text}
          placeholder={placeholder ?? labels.placeholder}
          disabled={disabled}
          readOnly={readOnly}
          required={required}
          aria-invalid={invalid || undefined}
          aria-describedby={describedBy}
          className={cn(
            "h-full min-w-0 flex-1 bg-transparent font-medium tabular-nums outline-none placeholder:font-normal placeholder:text-muted-foreground disabled:cursor-not-allowed",
            // 有条内标签时号码靠右,与方条开关、金额输入的「左标签右数值」一致
            label && "text-right",
          )}
          onChange={(event) => {
            const input = event.currentTarget
            commit(input.value, input.selectionStart ?? input.value.length)
          }}
          onKeyDown={(event) => {
            props.onKeyDown?.(event)
            const input = event.currentTarget
            const { selectionStart: start, selectionEnd: end } = input
            if (
              event.defaultPrevented ||
              readOnly ||
              start === null ||
              start !== end
            )
              return
            // 光标紧贴分隔空格时,退格 / 删除越过空格去删旁边的数字,否则删了空格又长回来
            const current = input.value
            if (event.key === "Backspace" && current[start - 1] === " ") {
              event.preventDefault()
              commit(
                current.slice(0, start - 2) + current.slice(start),
                start - 2,
              )
            } else if (event.key === "Delete" && current[start] === " ") {
              event.preventDefault()
              commit(current.slice(0, start) + current.slice(start + 2), start)
            }
          }}
          onFocus={onFocus}
          onBlur={(event) => {
            setTouched(true)
            onBlur?.(event)
          }}
        />
        {action && (
          <PhoneInputContext.Provider value={{ valid }}>
            {action}
          </PhoneInputContext.Provider>
        )}
      </div>
      {errorMessage && (
        <p
          id={errorId}
          data-slot="phone-input-error"
          className="text-xs text-destructive"
        >
          {errorMessage}
        </p>
      )}
      {name && (
        <input
          type="hidden"
          data-slot="phone-input-value"
          name={name}
          form={form}
          value={value}
          disabled={disabled}
        />
      )}
    </div>
  )
}

export {
  PhoneInput,
  PhoneInputSendButton,
  formatPhone,
  isValidPhone,
  maskPhone,
  phoneInputControlVariants,
  useSendCode,
}
export type {
  PhoneInputProps,
  PhoneInputSendButtonProps,
  PhoneInputSize,
} from "./types"
components/ui/phone-input/phone-input-parts.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"

import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"

import { DEFAULT_COOLDOWN, DEFAULT_LABELS } from "./config"
import type { PhoneInputSendButtonProps } from "./types"

/**
 * 手机号方条:与方条开关、金额输入同一套外观(浅灰底、细边框、标签在条内左侧)。
 * 高度只来自既有三档 28 / 32 / 36px,触屏设备上各加高一档(36 / 40 / 44px);
 * 手机上字号保持 16px,iOS 聚焦时才不会自动放大页面
 */
const phoneInputControlVariants = cva(
  "flex w-full min-w-0 cursor-text items-center gap-3 rounded-md border bg-muted/60 px-3 transition-colors",
  {
    variants: {
      size: {
        sm: "h-7 text-base pointer-coarse:h-9 md:text-xs",
        default: "h-8 text-base pointer-coarse:h-10 md:text-sm",
        lg: "h-9 text-base pointer-coarse:h-11 md:text-sm",
      },
    },
    defaultVariants: { size: "default" },
  },
)

/** 手机号方条把「号码是否有效」告诉条内的发送按钮:号码不完整时按钮自动置灰 */
const PhoneInputContext = React.createContext<{ valid: boolean } | null>(null)

type SendState = "idle" | "sending" | "counting" | "again"

/**
 * 「获取验证码」的状态:发送中 → 倒计时 → 可以重新获取。
 * 剩余秒数按截止时间算,页面切到后台计时器变慢也不会多等。
 */
function useSendCode(onSend: () => void | Promise<void>, cooldown: number) {
  const [state, setState] = React.useState<SendState>("idle")
  const [deadline, setDeadline] = React.useState(0)
  const [now, setNow] = React.useState(0)

  React.useEffect(() => {
    if (state !== "counting") return
    const timer = window.setInterval(() => {
      const current = Date.now()
      setNow(current)
      if (current >= deadline) setState("again")
    }, 250)
    return () => window.clearInterval(timer)
  }, [state, deadline])

  const send = async () => {
    if (state === "sending" || state === "counting") return
    const previous = state
    setState("sending")
    try {
      await onSend()
    } catch {
      // 发送失败不进入倒计时,用户可以马上重试;错误提示由调用方处理
      setState(previous)
      return
    }
    const start = Date.now()
    setNow(start)
    setDeadline(start + cooldown * 1000)
    setState("counting")
  }

  const remaining = Math.max(0, Math.ceil((deadline - now) / 1000))
  return { state, remaining, send }
}

/** 获取验证码按钮:放进手机号方条的 `action`,也可以单独用在邮箱等别的输入旁边 */
function PhoneInputSendButton({
  onSend,
  cooldown = DEFAULT_COOLDOWN,
  variant = "inline",
  size = "default",
  labels: labelsProp,
  disabled,
  className,
  ...props
}: PhoneInputSendButtonProps) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp }
  const context = React.useContext(PhoneInputContext)
  const { state, remaining, send } = useSendCode(onSend, cooldown)
  const busy = state === "sending" || state === "counting"
  const text =
    state === "sending"
      ? labels.sending
      : state === "counting"
        ? labels.countdown.replace("{seconds}", String(remaining))
        : state === "again"
          ? labels.resend
          : labels.send

  return (
    <button
      type="button"
      data-slot="phone-input-send"
      data-state={state}
      disabled={disabled || busy || context?.valid === false}
      // 倒计时每秒都在变,不用 aria-live 逐秒播报;读屏聚焦时会念出当前文字
      onClick={() => void send()}
      className={cn(
        variant === "inline"
          ? // 条内:撑满条高、左侧一道竖线隔开号码,文字用主题色,像一段可点的链接
            "-mr-3 flex h-full shrink-0 items-center border-l border-edge px-3 text-primary tabular-nums transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:bg-transparent max-md:text-sm @max-[20rem]/phone-input:px-2.5"
          : cn(buttonVariants({ variant: "outline", size }), "tabular-nums"),
        className,
      )}
      {...props}
    >
      {text}
    </button>
  )
}

export {
  PhoneInputContext,
  PhoneInputSendButton,
  phoneInputControlVariants,
  useSendCode,
}
components/ui/phone-input/types.ts
import type * as React from "react"

import type { PhoneInputLabels } from "./config"

type PhoneInputSize = "sm" | "default" | "lg"

type PhoneInputProps = Omit<
  React.ComponentProps<"input">,
  "value" | "defaultValue" | "onChange" | "type" | "size" | "children"
> & {
  /** 手机号,11 位纯数字字符串(不带空格、区号);空串表示没填。受控时传入 */
  value?: string
  /**
   * 非受控时的初始值
   * @default ""
   */
  defaultValue?: string
  /** 号码变化时回调,参数是纯数字字符串 */
  onValueChange?: (value: string) => void
  /** 写在条内左侧的标签,自动与输入框关联;传入后号码靠右 */
  label?: React.ReactNode
  /**
   * 档位:高度 28 / 32 / 36px,触屏设备上 36 / 40 / 44px
   * @default "default"
   */
  size?: PhoneInputSize
  /** 条内右侧的附加内容,通常放 `PhoneInputSendButton`(获取验证码) */
  action?: React.ReactNode
  /**
   * 错误提示:显示在条下方,条变红。不传时组件自己校验,失焦后位数不够或号段不对会提示
   */
  error?: React.ReactNode
  labels?: Partial<PhoneInputLabels>
}

type PhoneInputSendButtonProps = Omit<
  React.ComponentProps<"button">,
  "onClick" | "children"
> & {
  /**
   * 点击后执行的发送动作,返回 Promise 时等它完成再开始倒计时;抛错时不进入倒计时,可以马上重试
   */
  onSend: () => void | Promise<void>
  /**
   * 冷却时间(秒)
   * @default 60
   */
  cooldown?: number
  /**
   * `inline` 放在手机号方条里(左侧一道竖线、主题色文字);`button` 单独使用,和按钮同一外观
   * @default "inline"
   */
  variant?: "inline" | "button"
  /**
   * `button` 外观的高度档位,与按钮一致:28 / 32 / 36px
   * @default "default"
   */
  size?: "sm" | "default" | "lg"
  labels?: Partial<PhoneInputLabels>
}

export type { PhoneInputProps, PhoneInputSendButtonProps, PhoneInputSize }
components/ui/phone-input/utils.ts
import { PHONE_LENGTH } from "./config"

/** 全角数字转半角,其余非数字字符丢掉 */
function digitsOf(text: string) {
  let out = ""
  for (const ch of text) {
    const code = ch.charCodeAt(0)
    if (code >= 0xff10 && code <= 0xff19)
      out += String.fromCharCode(code - 0xfee0)
    else if (ch >= "0" && ch <= "9") out += ch
  }
  return out
}

/**
 * 去掉国际区号:「+86 138…」「0086 138…」「86138…」都剩下 11 位。
 * 只在数字超过 11 位时才剥,免得把正在输入的号码误删。
 */
function stripCountryCode(digits: string) {
  if (digits.length > PHONE_LENGTH && digits.startsWith("0086"))
    return digits.slice(4)
  if (digits.length > PHONE_LENGTH && digits.startsWith("86"))
    return digits.slice(2)
  return digits
}

/** 11 位数字按 3-4-4 加空格:`13800138000` → `138 0013 8000`;不足 11 位时按已有位数分 */
function formatPhone(digits: string) {
  const d = digits.slice(0, PHONE_LENGTH)
  if (d.length <= 3) return d
  if (d.length <= 7) return `${d.slice(0, 3)} ${d.slice(3)}`
  return `${d.slice(0, 3)} ${d.slice(3, 7)} ${d.slice(7)}`
}

/** 号码中间四位打码:`13800138000` → `138 **** 8000`;不是 11 位时原样返回 */
function maskPhone(digits: string, mask = "****") {
  const d = digitsOf(digits)
  if (d.length !== PHONE_LENGTH) return digits
  return `${d.slice(0, 3)} ${mask} ${d.slice(7)}`
}

/** 是否是有效的中国大陆手机号:11 位,以 13–19 开头 */
function isValidPhone(digits: string) {
  return /^1[3-9]\d{9}$/.test(digits)
}

/**
 * 整理一次输入:返回格式化后的文本、换算后的光标位置和纯数字值。
 * 光标按「它前面有几位数字」换算,所以增删空格、粘贴带横线的号码时光标都落在同一个数字后面。
 */
function sanitizePhone(raw: string, caret: number) {
  const before = digitsOf(raw.slice(0, caret)).length
  const all = digitsOf(raw)
  const stripped = stripCountryCode(all)
  const removed = all.length - stripped.length
  const digits = stripped.slice(0, PHONE_LENGTH)
  const text = formatPhone(digits)
  const want = Math.min(Math.max(before - removed, 0), digits.length)
  let position = 0
  let seen = 0
  while (position < text.length && seen < want) {
    if (text[position] !== " ") seen++
    position++
  }
  return { text, caret: position, digits }
}

export { digitsOf, formatPhone, isValidPhone, maskPhone, sanitizePhone }
components/ui/phone-input/config.ts
/** 用户可见文案。`{seconds}` 会被替换成剩余秒数。 */
const DEFAULT_LABELS = {
  /** 输入框的提示文字 */
  placeholder: "请输入手机号",
  /** 位数不够或号段不对时的提示,失焦后显示 */
  invalid: "请输入正确的手机号",
  /** 必填却没填时的提示 */
  required: "请输入手机号",
  /** 发送验证码按钮:初始 */
  send: "获取验证码",
  /** 发送中 */
  sending: "发送中…",
  /** 倒计时中 */
  countdown: "{seconds} 秒后重发",
  /** 倒计时结束 */
  resend: "重新获取",
}

type PhoneInputLabels = typeof DEFAULT_LABELS

/** 中国大陆手机号的位数 */
const PHONE_LENGTH = 11

/** 发送验证码后的默认冷却时间(秒) */
const DEFAULT_COOLDOWN = 60

export { DEFAULT_COOLDOWN, DEFAULT_LABELS, PHONE_LENGTH }
export type { PhoneInputLabels }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }