You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React useContext setState仅首次更新:Firestore邮票监听场景求助

问题:扫码集邮APP的Firestore快照监听器状态更新异常

我开发了一款允许用户扫码集邮的APP,通过Firebase Firestore快照监听器监听用户新增的邮票,希望利用快照的payload将新邮票添加到Context存储的邮票列表中,避免重新拉取全部邮票数据。但该功能仅在APP启动后的第一次操作正常,后续操作会出现异常:

异常表现

  • 第一次扫码操作:收到包含新增邮票的快照后,新邮票正常添加到Context状态,控制台打印的邮票总数正确,日志显示添加成功。
  • 第二次及之后扫码操作:收到快照后,控制台打印的邮票总数仍是第一次操作前的旧值,新邮票被添加到旧的Context状态中,导致之前的邮票被覆盖。

代码结构

Context 定义

const AuthContextProvider = ({ children }: Props) => {
  const [user, setUser] = useState<FirebaseAuthTypes.User>();
  const [userData, setUserData] = useState<FirestoreUser>();
  const [stamps, setStamps] = useState<GetMyStampsResponse>();
  const [isLoading, setLoading] = useState(true);

  // 与问题无关的认证逻辑
  const onAuthStateChanged = useCallback(
    async (response: FirebaseAuthTypes.User | null) => {
       ...
    },
    [...]
  );

  useEffect(() => {
    const unsubscribe = auth().onUserChanged(onAuthStateChanged);
    return unsubscribe;
  }, [onAuthStateChanged]);

  return (
    <AuthContext.Provider
      value={{
        isLoading,
        stamps,
        setStamps,
        user,
        userData,
        reloadUser,
      }}>
      {children}
    </AuthContext.Provider>
  );
};

useAuth 钩子

import storage from '@react-native-firebase/storage';
import cloneDeep from 'lodash/cloneDeep';
import { useCallback, useContext } from 'react';

const useAuth = () => {
  const { stamps, setStamps, reloadUser, userData, user, isLoading } = 
    useContext(AuthContext);

  /**
   * 通过监听器添加新邮票,避免调用reloadUser(开销更大)
   */
  const addStampsFromListener = useCallback(
    async (newStamps: FirestoreStamp[]) => {
      let newStampsState = cloneDeep(stamps);
      const newStampHasKnownStampcard = newStampsState?.cards.find(
        c => c.ref.path === newStamps[0].stampcardRef.path,
      );

      console.log('current stamps state', newStampsState?.stamps.length);

      if (newStampHasKnownStampcard) {
        newStampsState = {
          ...newStampsState!,
          stamps: newStampsState!.stamps.concat(newStamps),
        };
      } else {
        const [stampcard, business] = await Promise.all([
          stampcardApi.getStampcardByRef(newStamps[0].stampcardRef),
          businessApi.getBusiness(newStamps[0].businessId),
        ]);
        const imageUrl = await storage()
          .ref(stampcard!.stampImage)
          .getDownloadURL();

        const stampcardWithRef: StampCardDataWithRef = {
          ...stampcard!,
          imageUrl,
          businessId: newStamps[0].businessId,
          ref: newStamps[0].stampcardRef,
        };
        newStampsState = {
          ...newStampsState,
          businesses: (newStampsState?.businesses ?? []).concat(business!),
          cards: (newStampsState?.cards ?? []).concat(stampcardWithRef),
          stamps: (newStampsState?.stamps ?? []).concat(newStamps),
        };
      }
      setStamps(newStampsState);

      return newStampsState;
    },

    [setStamps, stamps],
  );

  ...

  return {
    addStampsFromListener,
    updateStampsFromListener,
    isLoading,
    data: userData,
    logout,
    reloadUser,
    stamps,
    user,
  };

};

屏幕组件中的监听器使用

const HomeConsumerScreen = () => {
  const {
    stamps: stampcards,
    reloadUser,
    addStampsFromListener,
    updateStampsFromListener,
  } = useAuth();

const subscribeToStamps = useCallback(() => {
    if (refIsSubscribedToStamps.current) return; // 避免重复订阅

    const unsubscribe = stampApi.subscribeToStamps(
      /**
       * 新邮票回调
       */
      querySnapshot => {
        error && setError(undefined);
        if (!refIsSubscribedToStamps.current) {
          // 第一次快照包含用户所有现有邮票,跳过处理
          return;
        }

        const data = querySnapshot.map(doc =>
          doc.doc.data(),
        ) as FirestoreStamp[];

        addStampsFromListener(data).then(nextState =>
          showDone('newStamp', data, nextState),
        );
      },
      /**
       * 初始化完成回调
       */
      () => {
        refIsSubscribedToStamps.current = true;
      },
      /**
       * 错误回调
       */
      e => {
        setError(e.message);
        console.error('onSnapShot', e);
      },
    );

    return unsubscribe;
  }, [addStampsFromListener, error, showDone, updateStampsFromListener]);

  useEffect(() => {
    // 订阅邮票变化
    const unsubscribe = subscribeToStamps();

    return () => {
      unsubscribe && unsubscribe();
      refIsSubscribedToStamps.current = false;
    };
    // 注:将subscribeToStamps加入依赖会导致监听器反复卸载/挂载,开销大,但能临时解决问题
  }, []);

};

问题原因

  1. 闭包捕获旧状态:addStampsFromListener通过useCallback依赖捕获了stamps状态,第一次订阅监听器时,该函数绑定的是初始的stamps值。后续stamps更新后,监听器回调仍引用旧的addStampsFromListener(因为useEffect依赖为空数组,未重新订阅),导致每次更新都基于旧状态。
  2. useEffect依赖缺失:HomeConsumerScreen中的useEffect依赖为空数组,subscribeToStamps仅在组件首次渲染时执行一次,监听器回调始终使用第一次渲染时的addStampsFromListener,而该函数已过时,无法获取最新的stamps状态。

解决方案

1. 使用函数式更新获取最新状态

修改addStampsFromListener,通过setStamps的函数式更新获取最新状态,避免直接依赖stamps,同时调整异步逻辑的位置:

const addStampsFromListener = useCallback(
  async (newStamps: FirestoreStamp[]) => {
    // 先判断是否需要异步获取数据
    const hasKnownStampcard = stamps?.cards?.find(
      c => c.ref.path === newStamps[0].stampcardRef.path
    );

    if (hasKnownStampcard) {
      // 直接用函数式更新添加邮票
      setStamps(prev => ({
        ...prev,
        stamps: [...(prev?.stamps || []), ...newStamps]
      }));
    } else {
      // 异步获取卡片和商家数据
      const [stampcard, business] = await Promise.all([
        stampcardApi.getStampcardByRef(newStamps[0].stampcardRef),
        businessApi.getBusiness(newStamps[0].businessId),
      ]);
      const imageUrl = await storage()
        .ref(stampcard!.stampImage)
        .getDownloadURL();

      const stampcardWithRef = {
        ...stampcard!,
        imageUrl,
        businessId: newStamps[0].businessId,
        ref: newStamps[0].stampcardRef,
      };

      // 函数式更新合并所有数据
      setStamps(prev => ({
        ...prev,
        businesses: [...(prev?.businesses || []), business!],
        cards: [...(prev?.cards || []), stampcardWithRef],
        stamps: [...(prev?.stamps || []), ...newStamps]
      }));
    }
  },
  [setStamps, stamps] // 这里仍保留stamps用于初始判断,后续可进一步优化
);

2. 用Ref保存最新的回调函数

在HomeConsumerScreen中使用useRef保存最新的addStampsFromListener,确保监听器回调能获取到最新的函数版本,避免频繁重新订阅监听器:

const HomeConsumerScreen = () => {
  const {
    stamps: stampcards,
    reloadUser,
    addStampsFromListener,
    updateStampsFromListener,
  } = useAuth();

  // 保存最新的addStampsFromListener
  const latestAddStamps = useRef(addStampsFromListener);
  useEffect(() => {
    latestAddStamps.current = addStampsFromListener;
  }, [addStampsFromListener]);

  const subscribeToStamps = useCallback(() => {
    if (refIsSubscribedToStamps.current) return;

    const unsubscribe = stampApi.subscribeToStamps(
      querySnapshot => {
        error && setError(undefined);
        if (!refIsSubscribedToStamps.current) {
          return;
        }

        const data = querySnapshot.map(doc =>
          doc.doc.data(),
        ) as FirestoreStamp[];

        // 调用Ref中保存的最新函数
        latestAddStamps.current(data).then(nextState =>
          showDone('newStamp', data, nextState),
        );
      },
      () => {
        refIsSubscribedToStamps.current = true;
      },
      e => {
        setError(e.message);
        console.error('onSnapShot', e);
      },
    );

    return unsubscribe;
  }, [error, showDone, updateStampsFromListener]); // 移除addStampsFromListener依赖

  useEffect(() => {
    const unsubscribe = subscribeToStamps();

    return () => {
      unsubscribe && unsubscribe();
      refIsSubscribedToStamps.current = false;
    };
  }, [subscribeToStamps]); // 加入subscribeToStamps依赖,但因其依赖稳定,不会频繁触发重新订阅
};

3. 优化初始快照处理逻辑

确保refIsSubscribedToStamps.current = true仅在初始快照(包含所有现有邮票)处理完成后设置,避免将初始快照误判为新增邮票:

// 修改subscribeToStamps中的初始化回调逻辑
() => {
  // 确认初始快照已处理完成后再标记为已订阅
  refIsSubscribedToStamps.current = true;
},

内容的提问来源于stack exchange,提问作者GunnarK

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.06 17:10:19