Home / Articles / Layering State in a React Streaming App: Context, Redux Toolkit and RTK Query

This article is published in English.

Layering State in a React Streaming App: Context, Redux Toolkit and RTK Query

Follow a video streaming frontend from prop drilling to nested providers to Redux Toolkit slices and RTK Query, and learn which kind of state belongs in which tool.

1694 words

Almost every growing React app reaches the point where the root component is buried under a stack of providers and a playback timer somehow re-renders the notification bell. The fix is rarely a single library; it is recognizing that different kinds of state need different homes. This walkthrough uses a video streaming frontend as a case study, moving from prop drilling through Context API to Redux Toolkit and RTK Query, and ends with a practical rule for choosing between them.

The example: a streaming frontend

Imagine the core features of an anime or video streaming service:

  • sign-in and a subscription tier, free or premium
  • a watchlist and a "continue watching" row
  • player state: the current episode, playback progress and quality setting
  • catalog browsing and search with genre filters
  • notifications for new episodes and subscription reminders

Each of these has to live somewhere in the component tree, and several components that sit far apart need to read them. That combination is exactly where early state decisions either pay off or turn into slow, expensive refactors months later.

Stage one: prop drilling

The first instinct is to lift state to the closest common ancestor and pass it down. For small apps that is the right call. In a streaming UI, though, the path from the root to a button can be long:

App → MainLayout → ContentSection → AnimeGrid → AnimeCard → PlayButton

Suppose PlayButton must know the subscription tier to decide whether to show a premium lock icon. The tier has to be passed through MainLayout, ContentSection and AnimeGrid, none of which use it. They exist in that chain only as couriers.

One drilled prop is tolerable. The trouble starts when a second, unrelated value such as the watchlist has to travel the same route. Every intermediate component now carries props it does not understand, which makes it harder to reuse elsewhere, harder to test in isolation and harder to reason about. Before reaching for a library, it is worth reading why prop drilling alone is not a reason to install Redux or Zustand; composition often shortens these chains. In this app, however, the data really is global.

Stage two: Context and the provider pyramid

React's Context API is the natural next step. You create an AuthContext, wrap the tree in an AuthProvider, and PlayButton reads the tier directly with useContext(AuthContext). The drilling disappears.

Then the other global concerns arrive, each with its own provider, and the root starts to look like this:

<AuthProvider>
  <SubscriptionProvider>
    <WatchlistProvider>
      <PlayerProvider>
        <NotificationProvider>
          <ThemeProvider>
            <App />
          </ThemeProvider>
        </NotificationProvider>
      </PlayerProvider>
    </WatchlistProvider>
  </SubscriptionProvider>
</AuthProvider>

This is what developers call provider hell: the app root becomes a set of nested wrappers, each adding a layer of indirection. The visual noise is the least of the problems.

Debugging requires a map

When playback misbehaves, you first need to know which provider owns that state and then trace the nesting to find where it changes. The tree itself does not tell you.

Every update reaches every consumer

A context value change re-renders all components that consume that context, even those that only use a small part of it. Continue-watching requires saving playbackProgress every few seconds. If that value lives in PlayerContext alongside the quality setting, a component that only displays the quality badge still re-renders on every tick.

Provider order becomes an implicit contract

WatchlistProvider needs the signed-in user's ID from AuthProvider, so it must be nested inside it. Nothing in the JSX makes that dependency explicit, and reordering providers during a refactor can break the app in ways that are hard to trace.

A realistic symptom: a team combines a player context and a notification context, and profiling reveals that progress updates are triggering notification re-renders. Nothing is visibly broken, but the React Profiler shows far more renders than the UI needs.

Context can be tuned, for example by splitting fast-changing values into their own context or memoizing provider values, but each workaround adds more providers and more ceremony. At that point a dedicated store is often simpler.

Stage three: Redux Toolkit slices

Many teams now reach for lighter stores such as Zustand at this stage. Redux Toolkit remains a strong choice when you have several related slices of state, want time-travel debugging and value a single predictable source of truth, and it removes most of the boilerplate that made classic Redux painful.

Each concern becomes a slice. The player slice below holds the current episode, progress and quality, and defines reducers to change the episode and update progress. Notice that the reducers appear to mutate state directly; Redux Toolkit uses Immer under the hood, so these assignments produce new immutable state safely:

// playerSlice.js
const playerSlice = createSlice({
  name: 'player',
  initialState: {
    currentEpisode: null,
    playbackProgress: 0,
    quality: '1080p',
  },
  reducers: {
    setEpisode: (state, action) => {
      state.currentEpisode = action.payload;
    },
    updateProgress: (state, action) => {
      state.playbackProgress = action.payload;
    },
  },
});

The nesting is gone. A single <Provider store={store}> wraps the app, and components read only what they need with useSelector. Because useSelector compares the selected value between renders, PlayButton selecting the subscription tier re-renders only when the tier changes, not every time progress ticks. That selective subscription model eliminates most of the unnecessary renders the Context version produced. Selectors still need care: returning a freshly created object or array from a selector defeats the comparison and brings the extra renders back.

The other major benefit is Redux DevTools. Stepping through each dispatched action, such as play pressed, progress updated or episode changed, and seeing exactly how state evolved makes playback bugs much easier to diagnose than tracing values through a pyramid of providers.

Stage four: RTK Query for server state

Much of the app's complexity is not UI state at all. It is server state: the catalog, search results, episode details and the watchlist stored on the backend. The traditional approach pairs useEffect with several useState calls per request to track data, loading and errors by hand, and it tends to produce race conditions and duplicate fetches.

RTK Query replaces that with an API slice. The definition below sets a base URL and declares two query endpoints, one for anime filtered by genre and one for episode details:

export const catalogApi = createApi({
  reducerPath: 'catalogApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getAnimeList: builder.query({
      query: (genre) => `/anime?genre=${genre}`,
    }),
    getEpisodeDetails: builder.query({
      query: (episodeId) => `/episodes/${episodeId}`,
    }),
  }),
});

RTK Query generates a React hook for each endpoint, named after it, which you export from the slice:

export const { useGetAnimeListQuery, useGetEpisodeDetailsQuery } = catalogApi;

A component then gets data, loading and error state in one line:

const { data: animeList, isLoading, error } = useGetAnimeListQuery('action');

There is no hand-written effect and no manual loading flag. The standout feature is caching. If a user opens the Action genre, navigates away and returns, the cached list appears immediately while RTK Query refetches in the background if the data is stale. Identical requests from multiple components share one network call.

For the continue-watching row, tag-based invalidation keeps the UI accurate: queries declare what they provide with providesTags, and mutations declare what they change with invalidatesTags. When a progress update mutation runs, RTK Query refetches the affected queries automatically, so no component has to trigger a refetch manually. Tags require a tagTypes entry on the API slice and a mutation endpoint, neither of which appears in the snippet above; our guide to sending data with RTK Query mutations shows that part. Also remember that the API slice's reducer and middleware must be added to the store for caching to work.

Matching each kind of state to a tool

For an app like this, a sensible split looks like this:

  • Local state with useState for anything that never leaves the component: form inputs, toggles, hover and open states.
  • Context API for simple global values that change rarely, such as theme or language. It struggles once there are many contexts or values that update frequently.
  • Redux Toolkit for complex, interconnected client state such as auth, subscription tier, player state and watchlist, read and written by many unrelated components.
  • RTK Query for everything that comes from the backend, removing a whole class of bugs: stale data, request races and redundant fetches.

The common mistake is treating this as an all-or-nothing choice, putting everything in Context or everything in Redux. These tools solve different problems, and a mature app usually combines them.

Key takeaways

  • Prop drilling is a signal to restructure first; reach for global state only when the data is genuinely shared across distant parts of the tree.
  • Context re-renders every consumer on each change, which makes it a poor home for high-frequency values like playback progress.
  • Hidden ordering dependencies between providers are a maintenance risk that grows with every new context.
  • Redux Toolkit's selector-based subscriptions and DevTools make shared client state both faster and easier to debug.
  • Keep server state out of hand-rolled effects: RTK Query's caching and tag invalidation handle freshness for you, as long as the store and tags are wired correctly.