Deepen PracticeOrdered learning track

Fetching with Effects: Why It Breaks Down

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 068

Fetching in useEffect can work, but it breaks down quickly as an application needs SSR, caching, deduplication, race safety, retry, cancellation, invalidation, pagination, optimistic updates, and cross-screen consistency.

10 min read1984 words
PrevNext
Lesson 68123 lesson track68–101 Deepen Practice
#react#hooks#useeffect#server-state+4 more

Part 068 — Fetching with Effects: Why It Breaks Down

Fetching data inside useEffect is not illegal.

The problem is more precise:

Fetching server product data directly inside component effects
usually makes the component responsible for too many distributed-systems concerns.

This part does not say “never fetch in an effect.” That would be too simplistic.

Instead, we will build the failure model.

By the end, you should be able to look at any useEffect(fetch...) and decide whether it is:

- a valid local synchronization effect
- a temporary implementation
- an accidental query cache
- a race condition waiting to happen
- a server-state architecture smell

1. The Naive Fetch Effect

Most React developers write this early:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then((response) => response.json())
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!user) return null;

  return <ProfileView user={user} />;
}

This code looks reasonable.

But it has hidden policy decisions:

- no server rendering data
- no preload
- no cache
- no request dedupe
- no stale response guard
- no abort policy
- no retry policy
- no invalidation policy
- no background refresh
- no stale-while-revalidate
- no optimistic reconciliation
- no cross-screen consistency

That is acceptable only if those policies truly do not matter.

In product applications, they usually matter.


2. Effect Fetching Is Synchronization, Not Data Architecture

useEffect is designed to synchronize a component with an external system.

Examples of external synchronization:

- connect/disconnect WebSocket
- subscribe/unsubscribe browser event
- start/stop timer
- integrate third-party widget
- update document title
- imperatively control external object

Fetching can be synchronization:

Component says: for this prop/state, start this external request.

But server data fetching for product UI is usually not just synchronization.

It also needs:

- identity
- cache
- freshness
- invalidation
- dedupe
- retry
- cancellation
- optimistic mutation
- request sharing
- SSR/preload
- hydration
- observability

useEffect gives you a lifecycle hook.

It does not give you a server-state system.


3. Failure Mode: Race Conditions

Consider this component:

function CaseDetail({ caseId }: { caseId: string }) {
  const [caseData, setCaseData] = useState<Case | null>(null);

  useEffect(() => {
    getCase(caseId).then(setCaseData);
  }, [caseId]);

  return <CaseView data={caseData} />;
}

Timeline:

T0: caseId = A, request A starts
T1: user switches to caseId = B, request B starts
T2: request B resolves, UI shows case B
T3: request A resolves late, UI shows case A incorrectly

Nothing in setCaseData knows which request is current.

A basic guard:

useEffect(() => {
  let ignore = false;

  async function load() {
    const result = await getCase(caseId);
    if (!ignore) {
      setCaseData(result);
    }
  }

  load();

  return () => {
    ignore = true;
  };
}, [caseId]);

This prevents old response from writing state after cleanup.

But now every component fetch must remember to implement race safety.

That is already a sign that the concern wants abstraction.


4. Failure Mode: Abort Is Not the Same as Ignore

Ignoring a stale response prevents state corruption.

Aborting a request prevents unnecessary work when supported.

useEffect(() => {
  const controller = new AbortController();

  async function load() {
    try {
      const response = await fetch(`/api/cases/${caseId}`, {
        signal: controller.signal,
      });

      const result = await response.json();
      setCaseData(result);
    } catch (error) {
      if (controller.signal.aborted) return;
      setError(error);
    }
  }

  load();

  return () => {
    controller.abort();
  };
}, [caseId]);

But aborting does not solve every race:

- server may have already processed request
- non-fetch async APIs may not support abort
- mutation commands may not be safely abortable
- response may still need request identity check

Abort is useful. It is not a complete data architecture.


5. Failure Mode: Duplicate Requests

If two components need the same data:

function Header() {
  const user = useCurrentUserEffectFetch();
  return <Avatar user={user} />;
}

function Sidebar() {
  const user = useCurrentUserEffectFetch();
  return <UserMenu user={user} />;
}

Each component may fetch independently.

A query cache would dedupe by key:

['currentUser'] -> one in-flight request shared by observers

Without a cache, dedupe becomes manual and scattered.


6. Failure Mode: Network Waterfalls

Parent fetches first. Child fetches after parent renders.

function CasePage({ caseId }: { caseId: string }) {
  const caseData = useCaseEffectFetch(caseId);

  if (!caseData) return <Spinner />;

  return <AuditTrail caseId={caseData.id} />;
}

function AuditTrail({ caseId }: { caseId: string }) {
  const auditTrail = useAuditTrailEffectFetch(caseId);
  return <AuditTrailView items={auditTrail} />;
}

Timeline:

T0: render CasePage
T1: effect starts case request
T2: case response returns
T3: render AuditTrail
T4: effect starts audit request
T5: audit response returns

The requests are sequential even if they could have been planned earlier.

Better architecture often preloads or starts independent requests closer to the route/page boundary.

Effects run after render, so they are naturally late for initial data loading.


7. Failure Mode: No Server Rendering Data

Effects do not run during server render.

That means if the only data load happens in useEffect, the initial HTML cannot include the fetched data.

Typical sequence:

1. Server returns HTML with loading shell.
2. Browser downloads JS.
3. React hydrates.
4. Effect runs.
5. Fetch starts.
6. Data arrives.
7. Real content appears.

For internal dashboards, this may be acceptable.

For public pages, SEO-sensitive pages, slow networks, or high-latency regions, this can be poor UX.

Framework loaders, server components, route preloading, or server-state dehydration address this boundary better than component-local effects.


8. Failure Mode: Loading State Is Underspecified

Naive state:

const [loading, setLoading] = useState(false);

But real server-state loading has multiple meanings:

- first load with no data
- background refetch with existing data
- refetch after error
- retry after failure
- mutation pending
- pagination next page loading
- dependent query waiting for input

A single boolean cannot express this.

Better read model:

type ReadModel<T> =
  | { status: 'idle' }
  | { status: 'pending' }
  | { status: 'success'; data: T; isFetching: boolean; isStale: boolean }
  | { status: 'error'; error: unknown }
  | { status: 'error-with-data'; data: T; error: unknown; isFetching: false };

Even if a library exposes booleans, your UI should think in states, not random flags.


9. Failure Mode: Error State Is Underspecified

Naive error:

const [error, setError] = useState<unknown>(null);

But server errors differ:

- unauthenticated
- unauthorized
- validation error
- conflict
- not found
- timeout
- offline
- server error
- partial failure
- stale version
- rate limit

UI behavior should differ:

401 unauthenticated:
  redirect/login refresh

403 unauthorized:
  show permission message

409 conflict:
  reload/merge/retry flow

422 validation:
  attach field errors

500:
  retry/report fallback

A component-local effect often collapses error semantics into one UI branch.

That is not enough for production workflows.


10. Failure Mode: Cache Is Accidentally Reimplemented

Developers notice duplicate fetches and add a module-level cache:

const userCache = new Map<string, User>();

export async function getCachedUser(userId: string) {
  const cached = userCache.get(userId);
  if (cached) return cached;

  const user = await getUser(userId);
  userCache.set(userId, user);
  return user;
}

Now ask:

- When does cache expire?
- How do listeners know it changed?
- How do in-flight requests dedupe?
- How are errors cached?
- How does logout clear it?
- How does mutation update it?
- How does SSR avoid cross-request leakage?
- How does tab focus trigger refetch?
- How does memory get released?

You started with useEffect and slowly built a worse query cache.

This is the common path.


11. Build From Scratch: From Effect to Resource Hook

Let us build the abstraction pressure step by step.

Step 1 — Naive Hook

function useCaseNaive(caseId: string) {
  const [data, setData] = useState<Case | null>(null);
  const [error, setError] = useState<unknown>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    setError(null);

    getCase(caseId)
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [caseId]);

  return { data, error, loading };
}

Bug: stale response can write.

Step 2 — Ignore Stale Response

function useCaseWithIgnore(caseId: string) {
  const [state, setState] = useState<{
    data: Case | null;
    error: unknown;
    loading: boolean;
  }>({ data: null, error: null, loading: false });

  useEffect(() => {
    let ignore = false;

    setState((prev) => ({ ...prev, loading: true, error: null }));

    getCase(caseId)
      .then((data) => {
        if (!ignore) {
          setState({ data, error: null, loading: false });
        }
      })
      .catch((error) => {
        if (!ignore) {
          setState((prev) => ({ ...prev, error, loading: false }));
        }
      });

    return () => {
      ignore = true;
    };
  }, [caseId]);

  return state;
}

Better, but repeated everywhere.

Step 3 — Request Identity

function useCaseWithRequestId(caseId: string) {
  const requestIdRef = useRef(0);
  const [state, setState] = useState<ReadState<Case>>({ status: 'idle' });

  useEffect(() => {
    const requestId = ++requestIdRef.current;

    setState({ status: 'pending' });

    getCase(caseId)
      .then((data) => {
        if (requestId !== requestIdRef.current) return;
        setState({ status: 'success', data });
      })
      .catch((error) => {
        if (requestId !== requestIdRef.current) return;
        setState({ status: 'error', error });
      });
  }, [caseId]);

  return state;
}

Now old responses cannot win.

But still no cache/dedupe.

Step 4 — Add In-Flight Deduplication

type ResourceRecord<T> = {
  promise: Promise<T> | null;
  data: T | null;
  error: unknown;
};

const caseRecords = new Map<string, ResourceRecord<Case>>();

function getCaseRecord(caseId: string): ResourceRecord<Case> {
  let record = caseRecords.get(caseId);

  if (!record) {
    record = { promise: null, data: null, error: null };
    caseRecords.set(caseId, record);
  }

  return record;
}

function fetchCaseOnce(caseId: string) {
  const record = getCaseRecord(caseId);

  if (record.promise) {
    return record.promise;
  }

  record.promise = getCase(caseId)
    .then((data) => {
      record.data = data;
      record.error = null;
      return data;
    })
    .catch((error) => {
      record.error = error;
      throw error;
    })
    .finally(() => {
      record.promise = null;
    });

  return record.promise;
}

Now requests dedupe.

But consumers are not notified when cache changes.

Step 5 — Add Subscribers

type Listener = () => void;

const listenersByKey = new Map<string, Set<Listener>>();

function subscribe(key: string, listener: Listener) {
  let listeners = listenersByKey.get(key);

  if (!listeners) {
    listeners = new Set();
    listenersByKey.set(key, listeners);
  }

  listeners.add(listener);

  return () => {
    listeners!.delete(listener);
  };
}

function notify(key: string) {
  listenersByKey.get(key)?.forEach((listener) => listener());
}

Now we are building an external store.

React integration wants useSyncExternalStore.

Step 6 — Snapshot Contract

function getCaseSnapshot(caseId: string) {
  const record = getCaseRecord(caseId);

  if (record.data) {
    return { status: 'success' as const, data: record.data };
  }

  if (record.error) {
    return { status: 'error' as const, error: record.error };
  }

  return { status: 'pending' as const };
}

But this implementation allocates a new object every call.

For useSyncExternalStore, snapshots must be stable when data has not changed. Otherwise React may think the store changed continuously.

So now you need snapshot caching.

Step 7 — Staleness Policy

Add:

type ResourceRecord<T> = {
  data: T | null;
  error: unknown;
  promise: Promise<T> | null;
  updatedAt: number | null;
};

function isStale(record: ResourceRecord<unknown>, staleTimeMs: number) {
  if (record.updatedAt === null) return true;
  return Date.now() - record.updatedAt > staleTimeMs;
}

Now you need:

- staleTime
- cacheTime/gcTime
- refetch on focus
- refetch on reconnect
- manual invalidation
- retry
- backoff

This is the moment you realize the library exists for a reason.


12. The Component Should Not Own the Cache Protocol

A component is good at:

- rendering a read model
- collecting user intent
- owning local interaction state
- composing child views

A component is bad at owning every server-state protocol:

- cache identity
- stale policy
- dedupe
- retries
- invalidation
- hydration
- garbage collection
- optimistic transaction log

That protocol should be centralized in a server-state layer.


13. Strict Mode Makes Bad Effects More Visible

In development, React Strict Mode may run extra setup/cleanup cycles to expose bugs in effects.

If your effect is not cleanup-safe, duplicate work may appear.

Bad effect:

useEffect(() => {
  analytics.connect();
}, []);

No cleanup.

Better:

useEffect(() => {
  const connection = analytics.connect();

  return () => {
    connection.disconnect();
  };
}, []);

For fetch effects, development behavior can reveal duplicate request assumptions.

The answer is not to disable Strict Mode. The answer is to make effects idempotent and cleanup-safe, or move server-state fetching to an abstraction with dedupe.


14. Effects Do Not Preload

An effect starts after render commit.

That means data fetching in effects is reactive, not planned.

Preload-oriented systems start earlier:

- route loader knows needed data before component renders
- server component reads data on server
- query prefetch starts on hover/navigation intent
- framework fetches in parallel for matched routes

Effect fetching says:

Render first, discover data need later.

That is often too late.


15. Effects Do Not Model Query Identity

An effect dependency array is not a query key.

useEffect(() => {
  fetchCases(filters).then(setCases);
}, [filters]);

Problems:

- `filters` may be unstable object identity
- dependency identity may not equal server resource identity
- no cache identity exists outside this component
- invalidation cannot target this effect by key

A query key should be explicit and serializable enough for identity:

['cases', { status, assigneeId, page, sort }]

An effect dependency array answers:

When should this synchronization re-run?

A query key answers:

Which server snapshot is this?

Different questions.


16. Effects Do Not Model Invalidation

Suppose this effect loads case detail:

useEffect(() => {
  getCase(caseId).then(setCaseData);
}, [caseId]);

Elsewhere, mutation approves the case:

async function approve() {
  await approveCase(caseId);
}

How does the detail effect know it should refetch?

Options:

- manually call a refetch callback
- toggle some state dependency
- emit event bus event
- force remount
- put data in context/store
- use server-state cache invalidation

The first five often become ad hoc invalidation.

Server-state cache gives invalidation a first-class place.


17. Effects Do Not Model Pagination Well

Infinite loading requires:

- page params
- accumulated pages
- dedupe per page
- next page availability
- loading next page vs loading first page
- error for next page while existing pages remain
- refetch all pages
- remove/insert item across pages after mutation

Naive effect state grows quickly:

const [pages, setPages] = useState<CasePage[]>([]);
const [page, setPage] = useState(1);
const [loadingInitial, setLoadingInitial] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasNextPage, setHasNextPage] = useState(true);
const [error, setError] = useState<unknown>(null);

This is not just data fetching. It is a pagination cache.


18. Effects Do Not Model Retry Policy

Retry is not simply:

catch(() => loadAgain())

Good retry policy needs:

- which errors are retryable?
- how many attempts?
- exponential backoff?
- jitter?
- pause when offline?
- retry on reconnect?
- user-visible retry button?
- preserve existing data while retrying?

A component effect should not independently invent retry policy for every API call.


19. Effects Do Not Model Auth Refresh Well

Auth failure often needs centralized handling:

- token expired
- refresh token request
- original request retry
- refresh failure -> logout
- prevent multiple simultaneous refreshes
- clear caches by tenant/user

If every effect fetch owns this, auth logic becomes duplicated and inconsistent.

Put it in API client/server-state layer.


20. Effects Do Not Model Cache Garbage Collection

Once data is fetched, how long should it stay?

- until component unmounts?
- until tab closes?
- for 5 minutes?
- forever until logout?
- while there are active observers?
- persisted between sessions?

Component-local state dies on unmount.

Module-level cache may never die.

Server-state cache has explicit garbage-collection policy.


21. When Fetching in Effects Is Fine

Use effect fetching when the operation is truly component-local synchronization.

Good examples:

- load metadata for a user-selected local file
- call browser/device API after component appears
- fetch non-shared preview data for an isolated widget
- one-off integration with third-party imperative SDK
- fire-and-clean subscription to an external object

Conditions:

- no SSR/preload need
- no cross-screen sharing
- no invalidation graph
- no dedupe requirement
- no optimistic mutation
- race/cancellation handled
- component truly owns lifecycle

Example:

function FileMetadataPreview({ file }: { file: File | null }) {
  const [metadata, setMetadata] = useState<FileMetadata | null>(null);

  useEffect(() => {
    if (!file) {
      setMetadata(null);
      return;
    }

    let ignore = false;

    readFileMetadata(file).then((result) => {
      if (!ignore) {
        setMetadata(result);
      }
    });

    return () => {
      ignore = true;
    };
  }, [file]);

  return <MetadataView metadata={metadata} />;
}

This is a local async derivation from local input.

It is not shared server product data.


22. When Fetching in Effects Is a Smell

Smell checklist:

- Same data needed by multiple components
- Data should survive navigation
- Data should be prefetched
- Initial HTML should include data
- Mutation should invalidate it
- Stale data can be displayed while refetching
- Request should dedupe
- Retry policy matters
- Pagination/infinite query exists
- User can edit/mutate the resource
- Auth/permission/conflict errors matter
- Offline/reconnect/focus refetch matters
- Cache needs devtools/observability

If two or more apply, direct effect fetching is probably the wrong abstraction.


23. Alternative Data Boundaries

23.1 Route Loader

Useful when data belongs to route identity.

Route /cases/:caseId
  -> load case detail before rendering route

Good for:

- route-level data
- navigation-aware loading
- SSR framework integration
- parallel route matching

23.2 Server Components / Server Data Layer

Useful when framework supports server-first data reads.

Good for:

- data needed in initial HTML
- secrets/server-only logic
- avoiding client waterfall
- reducing client JS data orchestration

23.3 Server-State Cache

Useful for interactive client apps.

Good for:

- cached client-side reads
- background refetch
- mutations
- optimistic updates
- pagination
- cross-screen consistency

23.4 Custom Resource Store

Useful when requirements are special but limited.

Good for:

- browser API resource
- WebSocket-backed resource
- local-first sync object
- embedded SDK state

But if you rebuild query cache features, use query cache.


24. Migration Path from Effect Fetching

Stage 1 — Encapsulate

Move repeated fetch effect into a custom hook.

function useCase(caseId: string) {
  // still effect-based, but isolated
}

Stage 2 — Add Correctness Guards

Add:

- request identity
- cleanup/abort
- discriminated read state
- stable error handling

Stage 3 — Add Query Identity

Define resource key:

const caseKey = (caseId: string) => ['case', caseId] as const;

Even before adopting a library, this clarifies identity.

Stage 4 — Move to Server-State Cache

Replace internal effect with query/cache abstraction.

function useCase(caseId: string) {
  return useQuery({
    queryKey: caseKey(caseId),
    queryFn: () => getCase(caseId),
  });
}

Stage 5 — Define Mutation Invalidation

function useApproveCase() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: approveCase,
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({ queryKey: ['case', variables.caseId] });
      queryClient.invalidateQueries({ queryKey: ['cases'] });
    },
  });
}

The real improvement is not syntax.

The real improvement is that invalidation becomes part of architecture.


25. A Better Component Shape

Bad shape:

function CaseDetailPage({ caseId }: { caseId: string }) {
  const [caseData, setCaseData] = useState<Case | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<unknown>(null);
  const [isApproveModalOpen, setApproveModalOpen] = useState(false);

  useEffect(() => {
    // network lifecycle
  }, [caseId]);

  async function approve() {
    // mutation lifecycle + invalidation
  }

  return ...;
}

Better shape:

function CaseDetailPage({ caseId }: { caseId: string }) {
  const caseRead = useCase(caseId);
  const approveCase = useApproveCase();
  const [isApproveModalOpen, setApproveModalOpen] = useState(false);

  return (
    <CaseDetailView
      caseRead={caseRead}
      isApproveModalOpen={isApproveModalOpen}
      onOpenApproveModal={() => setApproveModalOpen(true)}
      onCloseApproveModal={() => setApproveModalOpen(false)}
      onConfirmApprove={() => approveCase.mutate({ caseId })}
    />
  );
}

Now responsibilities are clearer:

useCase:
  server read lifecycle

useApproveCase:
  server command lifecycle

local state:
  modal visibility

view:
  rendering and user intent

26. Suspense Does Not Magically Fix Bad Fetching

Suspense can improve loading boundary composition.

But Suspense is not cache identity by itself.

You still need:

- resource identity
- cache lifetime
- invalidation
- error boundary
- mutation lifecycle
- preloading strategy

Suspense answers:

How should UI wait for async data at render boundary?

It does not automatically answer:

When is this server snapshot stale?

27. React 19 Actions Do Not Replace Server-State Cache

React 19 Actions improve async transitions and form/mutation workflows.

They help with:

- pending state
- form submissions
- optimistic UI
- async transition handling

But they do not remove the need for read cache design.

After an action mutates server state, you still need to decide:

- which reads are stale?
- should data be revalidated?
- should cache be updated from action result?
- should optimistic state rollback?

Actions improve command ergonomics.

Server-state architecture still matters.


28. Testing Effect Fetching Is Awkward

Effect fetching tests need to handle:

- mount timing
- async resolution
- cleanup
- prop changes
- out-of-order responses
- unmount before response
- duplicate fetches
- loading/error transitions

If each component owns fetch lifecycle, each component test must repeat this.

With server-state hooks, tests can focus on:

- read hook behavior
- mutation invalidation
- view states
- integration journey

The boundary becomes testable.


29. Observability

In production, you need to answer:

- Which query is slow?
- Which endpoint retries often?
- Which cache invalidation is missing?
- Which mutation causes stale UI?
- Which screen waterfalls data?
- Which request was cancelled?
- Which error type dominates?

Scattered effects produce scattered telemetry.

A server-state layer can instrument:

- query key
- duration
- result type
- retry count
- cache hit/miss
- stale time
- invalidation source
- mutation lifecycle

Good frontend architecture is observable architecture.


30. Decision Matrix

RequirementEffect FetchRoute LoaderServer-State CacheCustom Resource Store
Component-local async syncGoodOverkillMaybeGood
SSR initial dataPoorGoodGood with dehydrationCustom work
Cache and stale policyManualFramework-specificGoodManual
DeduplicationManualGood if loader-levelGoodManual
Cross-screen sharingPoorMediumGoodMedium
Mutation invalidationManualRevalidation modelGoodManual
Optimistic updateManualLimitedGoodManual
Pagination/infiniteManual-heavyMediumGoodManual-heavy
Retry/focus/reconnectManualFramework-specificGoodManual
External browser/SDK syncGoodPoorPoor/MaybeGood

31. Practical Rule

Use this rule:

If the data is server-owned and may be reused, invalidated, prefetched,
refetched, mutated, cached, or shared, do not let a random component effect own it.

Use an effect only when:

The component truly owns the external synchronization lifecycle.

32. Common Refactor Recipes

Recipe 1 — Effect Fetch to Query Hook

Before:

useEffect(() => {
  getCase(caseId).then(setCase);
}, [caseId]);

After:

const caseRead = useCase(caseId);

Where:

function useCase(caseId: string) {
  return useQuery({
    queryKey: ['case', caseId],
    queryFn: () => getCase(caseId),
  });
}

Recipe 2 — Manual Refetch to Invalidation

Before:

await approveCase(caseId);
await reloadCase();
await reloadCases();

After:

await approveCase(caseId);
invalidateCaseRelatedSnapshots(caseId);

Recipe 3 — Derived Local Data to Selector

Before:

const [openCases, setOpenCases] = useState<Case[]>([]);

useEffect(() => {
  setOpenCases(cases.filter(isOpen));
}, [cases]);

After:

const openCases = useMemo(() => cases.filter(isOpen), [cases]);

Recipe 4 — Request Boolean to Read State

Before:

const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);

After:

type ReadState<T> =
  | { status: 'pending' }
  | { status: 'success'; data: T; isFetching: boolean }
  | { status: 'error'; error: unknown };

Recipe 5 — Event Bus Refresh to Query Key Invalidation

Before:

eventBus.emit('case:changed', caseId);

After:

queryClient.invalidateQueries({ queryKey: ['case', caseId] });
queryClient.invalidateQueries({ queryKey: ['cases'] });

33. Anti-Pattern Map

Anti-PatternSymptomBetter Direction
Fetch in every componentduplicate requestsquery cache or loader
Store API response in Contextrerender fan-out, manual invalidationserver-state cache
Copy query data to local statestale duplicated dataderive or explicit draft
Boolean loading everywhereinconsistent UI statesdiscriminated read state
Manual refresh callbacksbrittle dependency chaininvalidation graph
Mutation as setterlost error/rollback semanticscommand lifecycle
No request identitystale response overwriterequest guard/cache observer
No cache keycannot share/invalidateexplicit resource key
Effect waterfallslow initial loadroute/page-level preloading
Swallow all errorspoor UX/debuggingtyped error handling

34. Exercises

Exercise 1 — Audit Fetch Effects

Find all useEffect blocks that call fetch, API client, or async data loader.

Classify each:

A. valid local synchronization
B. should become custom hook
C. should become route loader
D. should become server-state query
E. should become mutation/action flow

Exercise 2 — Race Test

For one effect fetch, write a test where:

1. request A starts
2. props change
3. request B starts
4. request B resolves
5. request A resolves late

Expected:

UI must show B, never A.

Exercise 3 — Waterfall Diagram

Pick one route. Draw all data requests and when they start.

Mark which requests can be parallelized or prefetched.

Exercise 4 — Invalidation Map

Pick one mutation. Write:

mutation: approveCase(caseId)
invalidates:
  - ['case', caseId]
  - ['cases', filters?]
  - ['dashboardMetrics']
  - ['auditTrail', caseId]

Then check whether current code actually refreshes those snapshots.

Exercise 5 — Loading State Rewrite

Replace loading/error/data booleans with a discriminated union read state in one component.

Observe how many impossible states disappear.


35. Key Takeaways

1. Fetching in effects is allowed but limited.
2. Effects run after render, so they are late for initial data loading.
3. Direct effect fetching does not provide cache, preload, dedupe, retry, or invalidation.
4. Race conditions must be handled explicitly if you fetch in effects.
5. Abort and ignore are useful but not complete architecture.
6. Effect dependency arrays are not query keys.
7. Mutation invalidation is the real design problem.
8. If you keep adding cache features around effects, use a server-state abstraction.
9. Use effects for local external synchronization.
10. Use loaders/server-state cache/resource stores for product server data.

36. References

  • React Documentation — useEffect
  • React Documentation — Synchronizing with Effects
  • React Documentation — You Might Not Need an Effect
  • React Documentation — Lifecycle of Reactive Effects
  • React Documentation — State as a Snapshot
  • React Documentation — React 19 Actions
  • TanStack Query Documentation — Important Defaults
  • TanStack Query Documentation — Query Keys
  • TanStack Query Documentation — Query Cancellation
Lesson Recap

You just completed lesson 68 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.