React 自定义 Hook 实践:如何优雅管理复杂列表的筛选状态?

在 React/React Native 开发中,带有复杂筛选条件的列表页是一个极其常见的场景。看似简单的"改变条件 -> 重新请求数据"流程,在 React 异步更新机制的加持下,往往会衍生出诸多边缘问题,例如请求参数滞后重置信号丢失等。

今天我们通过实现一个专门用于管理列表筛选状态的 Hook ------ useFilters,来聊聊如何优雅地解决这些痛点,并在实践中规避 TypeScript 和 React State 机制的隐藏陷阱。

痛点分析:为什么单纯的 useState 不够用?

通常,我们习惯用 useState 来保存查询条件:

tsx 复制代码
const [filters, setFilters] = useState({ keyword: '', type: 1 });

const updateFilter = (key, value) => {
  setFilters(prev => ({ ...prev, [key]: value }));
  refresh(); // ❌ 这里拿到的依旧是旧 filters 参数
};

React 的 setState 是异步的,如果在更新状态后立即触发数据刷新,网络请求内部读到的依然是未更新的旧状态。为了解决这个问题,许多开发者会采用 useEffect 监听状态变化来触发请求。但在复杂业务逻辑中(包含防抖、多种触发源),过度依赖 useEffect 会让数据流变得难以追踪。

破局思路:State 负责 UI 渲染,Ref 负责逻辑读取。

核心 API 设计:State 与 Ref 的双重奏

为了兼顾"UI 响应式"和"随时获取最新参数",我们在 useFilters 中引入了 useRef 来同步追踪最新的筛选状态。

typescript 复制代码
import { useCallback, useRef, useState } from 'react';

export function useFilters<T extends object>(initial: T) {
  const [filters, setFiltersState] = useState<T>(initial);
  const filtersRef = useRef<T>(initial);

  const updateFilter = useCallback(<K extends keyof T>(key: K, value: T[K]) => {
    // 1. 立即更新同步的 Ref(供接口请求使用)
    filtersRef.current = { ...filtersRef.current, [key]: value };
    // 2. 触发异步的 State 更新(供 UI 重新渲染)
    setFiltersState(filtersRef.current);
  }, []);

  // ...
  return { filters, filtersRef, updateFilter };
}

通过这一层封装:

  • filters:响应式状态对象。用于驱动 UI 渲染(如输入框回显、Picker 选中态)。
  • filtersRef :同步引用对象。闭包或外部函数(如 fetcher)随时可通过 filtersRef.current 获取无延迟的最新参数,从此告别参数滞后。

深入细节:TypeScript 联合类型的类型收窄陷阱

在使用习惯上,我们希望 setFilters 能像原生的 setState 一样,既支持直接传入新对象,也支持传入基于旧状态计算新状态的 updater 函数:

typescript 复制代码
// 支持形态 A
setFilters({ keyword: '张三' });
// 支持形态 B
setFilters(prev => ({ ...prev, keyword: '张三' }));

这里入参的类型是联合类型 T | ((prev: T) => T)。在实现时,我们需要区分它:

typescript 复制代码
const setFilters = useCallback((next: T | ((prev: T) => T)) => {
  if (typeof next === 'function') {
    // ⚠️ 注意这里的类型断言
    filtersRef.current = (next as (prev: T) => T)(filtersRef.current);
  } else {
    filtersRef.current = next;
  }
  setFiltersState(filtersRef.current);
}, []);

为什么在 typeof next === 'function' 分支里还要写 as (prev: T) => T

理论上,TypeScript 应当能自动把 next 的类型收窄为函数。但实际上并不行 。这是 TS 的一个已知限制:当联合类型中同时包含「对象类型(T)」与「函数类型」时,因为函数本质上也是对象,TS 的类型收窄会趋于保守,导致 next 在分支内部依然被视为联合类型,无法直接调用。

有些开发者可能会直接写 (next as any)(...) 强行通过编译。但我们极力反对这么做------as any 会抹杀掉所有的类型检查 。如果未来函数签名调整为 (prev: T) => Partial<T>any 将无法抛出错误,造成运行时隐患。

经验总结:精确断言 as (prev: T) => Tas any 在运行时完全等价,但保留了严密的类型校验。处理联合类型里的函数分支时,请始终使用精确断言。

进阶场景:重置操作与"信号丢失"之谜

重置条件是一个非常特殊的动作:当我们点击"重置"按钮时,不仅要清空 UI 上的筛选框,还期望列表自动使用初始条件刷新一次

由于 updateFilter 不应与具体的请求逻辑(如 refresh)产生耦合,我们采用了一种**"埋点信号"**的机制:

typescript 复制代码
const resetSignalRef = useRef(false);

const resetFilters = useCallback(() => {
  const fresh = { ...initialRef.current };
  filtersRef.current = fresh;
  setFiltersState(fresh);
  resetSignalRef.current = true; // 埋下信号
}, []);

const consumeResetSignal = useCallback(() => {
  if (resetSignalRef.current) {
    resetSignalRef.current = false; // 消费信号
    return true;
  }
  return false;
}, []);

在组件端,我们通过 useEffect 响应这个信号:

tsx 复制代码
useEffect(() => {
  if (consumeResetSignal()) {
    refresh();
  }
}, [filters, refresh, consumeResetSignal]); // 依赖 filters 变化触发

隐藏的 Bug:React 的状态复用优化

上述代码曾经遭遇过一个隐蔽的 Bug:当用户进入页面后,没有修改任何筛选条件,直接点击"重置"按钮,列表毫无反应。而且,在此之后的第一次正常搜索,会意外触发两次请求。

问题出在哪里?出在 React 的内置优化上。

如果我们在 resetFilters 里直接赋值原引用:

typescript 复制代码
setFiltersState(initialRef.current);

当前状态 filters 已经等于初始状态时,Object.is(newState, currentState) 成立。React 判定状态无实质改变,直接跳过了本次重新渲染(Bailout),关联的 useEffect 也不会执行。

这就导致了灾难性的连锁反应:

  1. resetSignalRef.current = true 被悄悄埋下。
  2. Effect 没执行,信号没人消费,滞留在了内存里。
  3. 随后用户正常输入搜索,filters 改变,Effect 执行,读到了这个滞留的信号,造成意外的 refresh 动作。

破局之道:0 成本的浅拷贝

解决办法异常优雅,只需保证每次重置都产生一个新引用:

typescript 复制代码
// 浅拷贝产生新引用,打破 Object.is 判断
const fresh = { ...initialRef.current };
setFiltersState(fresh);

为什么不用深拷贝(Deep Clone)?

  1. React 只看外层引用:触发重绘只需最外层对象的内存地址改变。
  2. 保护下游性能优化 :浅拷贝复用了内层引用(如数组字段)。依赖内层字段的被 React.memo 包裹的子组件,不会发生无意义的重新计算,完美契合了 React 不可变(Immutable) 的设计哲学。

完整实战演练

最后,来看看 useFilters 与分页 Hook usePaginatedList 的丝滑配合:

tsx 复制代码
import { useCallback, useEffect } from 'react';
import { useFilters, usePaginatedList } from '@/hooks';

const INITIAL = { keyword: '', type: 1 };

const MyList = () => {
  const { filters, filtersRef, updateFilter, resetFilters, consumeResetSignal } = useFilters(INITIAL);

  // 1. fetcher 内部统一通过 filtersRef 读取参数
  const fetcher = useCallback((params) => {
    return queryApi({
      ...params,
      ...filtersRef.current,
    });
  }, []);

  const { refresh, data } = usePaginatedList({ fetcher });

  // 2. 统一响应重置信号
  useEffect(() => {
    if (consumeResetSignal()) {
      refresh();
    }
  }, [filters, refresh, consumeResetSignal]);

  return (
    <View>
      <SearchBar
        value={filters.keyword}
        onChange={(v) => updateFilter('keyword', v)}
        onSubmit={refresh}
        onReset={resetFilters}
      />
      {/* 列表渲染逻辑... */}
    </View>
  );
};

总结

一个看似简单的 useFilters,内部却大有乾坤:

  1. State 渲染,Ref 获取最新值,打破了 React 异步更新带来的延迟限制。
  2. 面对联合类型断言,摒弃 as any,坚持精确的函数签名断言
  3. 警惕 React setState 的**"引用相等跳过更新"机制**,利用一行浅拷贝巧妙消除跨渲染周期通信的隐藏 Bug。

良好的前端架构不仅仅在于使用高级的框架,更在于对框架底层的边界情况有着深入且清晰的掌控。希望这个小小的自定义 Hook 实战能给你带来启发!

相关推荐
EF@蛐蛐堂2 小时前
TanStack NPM攻击 揭秘及应对方案
前端·vue.js·npm·安全威胁分析
恋猫de小郭2 小时前
终于,Flutter 修复 Android 中文字体异常,但是很草台,不知怎么吐槽
android·前端·flutter
Cobyte2 小时前
11.响应式系统演进:深入剖析 computed 实现原理与性能优化实践(Vue3.3)
前端·javascript·vue.js
_Evan_Yao2 小时前
计算机大一新生如何选择方向(前端/后端/AI/运维)?
运维·前端·人工智能·后端
ZC跨境爬虫2 小时前
跟着MDN学HTML_day_46:(HTMLCollection与NodeList)
前端·javascript·ui·html·音视频
码途漫谈2 小时前
Scrapling:让爬虫在现代 Web 里“活下来”的自适应抓取框架
前端·爬虫·ai·开源
极梦网络无忧2 小时前
我开源了一个 Vue 3 动态表单组件库 —— real-vue3-easy-form
前端·vue.js·开源
ShyanZh2 小时前
【Claude基础】多代理协作:Agent Teams 与编排模式
前端·chrome·ai
下载居2 小时前
Google Chrome(谷歌浏览器64位) 148.0.7778
前端·chrome