SharePointVault ~/gotchas/spfx ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ autosave-effect-must-wait-for-async-inputs.md
sharepoint-vault›gotchas›spfx›autosave-effect-must-wait-for-async-inputs.mdhistoryedit on GitHub
● GOTCHAspfx· 2 min read

An auto-save effect computed from async-loaded inputs writes garbage on mount — gate it on a "loaded" flag

applies to: React 17+ (SPFx web parts, but general React)reviewed: 2026-07-18#spfx#react#hooks#data-integrity

A silent auto-save derived from async-loaded state must wait for an explicit "loaded" flag — a value-dedup ref stops save loops, not writes fired from still-empty inputs on mount.

Tiché auto-uložení odvozené z asynchronně načtených dat musí počkat na explicitní příznak „načteno“ – dedup ref proti hodnotě brání smyčkám, ne zápisu z prázdných vstupů hned po mountu.

Symptom

  • Opening a record detail briefly flashes a wrong derived value (e.g. a score of 0) and persists it.
  • If the user closes the panel quickly — or one of the fetches fails — the wrong value stays saved until the next visit. Most of the time a second, correct write follows the data load, which masks the bug.

Cause

A classic React pattern combined carelessly:

const [activities, setActivities] = useState([]);      // async-filled inputs start EMPTY
useEffect(() => { load().then(setActivities); }, []);

const result = useMemo(
  () => compute(record, { activities /* … */ }),        // computed from [] on first render
  [record, activities]);

useEffect(() => {                                       // silent auto-save of the computed value
  if (key(result) !== lastSavedKey.current) {
    lastSavedKey.current = key(result);
    svc.update(record.Id, result);                      // ← writes the "computed from nothing" value
  }
}, [result]);

The dedup ref (lastSavedKey) protects against save loops, not against saving too early: on mount the inputs are [], compute() yields a degraded result that differs from the stored one, and the effect happily persists it. Per-fetch .catch(() => []) fallbacks make it worse — a throttled request (HTTP 429) quietly removes one input and the auto-save writes an understated value even after "loading finished".

Fix

  1. Add a loaded flag set only when the inputs actually arrived, and gate the computation on it:
const [inputsLoaded, setInputsLoaded] = useState(false);
useEffect(() => {
  let cancelled = false;
  setInputsLoaded(false);
  Promise.all([loadActivities(), loadDeals(), loadEmails()])   // STRICT: no per-fetch catch → []
    .then(res => { if (cancelled) return; /* set states */ setInputsLoaded(true); })
    .catch(() => { /* show empty UI, but DO NOT auto-save */ });
  return () => { cancelled = true; };
}, [record]);

const result = useMemo(
  () => (inputsLoaded ? compute(record, inputs) : null),
  [inputsLoaded, record, inputs]);
  1. Read the compute inputs strictly — a partial failure must abort the auto-save, not degrade it.
  2. If a child panel receives related data via props that start as [] (drill-down hosts), pass a relLoaded prop along and gate the child's auto-save on it too.

Rule of thumb

Any silent write derived from asynchronously loaded state needs an explicit answer to "has everything I compute from actually loaded, successfully?" — a value-dedup ref does not answer that.

Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-07-18last updated: 2026-09-24historyedit on GitHubview source