Context and Scopes

The Problem: Prop Drilling

Some data can be needed in many places but declared in one, e.g. a theme, the logged-in user, the current locale. The top-level component may own it, yet components several levels down need to read it.

Without a way to share data, the only option is to pass it down as props through every layer in between:

function App() {
  const theme = 'dark';
  return <MainLayout theme={theme} />;
}

function MainLayout({ theme }) {
  // MainLayout doesn't care about the theme...
  return <Sidebar theme={theme} />;
}

function Sidebar({ theme }) {
  // ...neither does Sidebar. It only forwards it.
  return <ThemedButton theme={theme} />;
}

function ThemedButton({ theme }) {
  // This is the component that actually uses it.
  return (
    <button class={theme === 'dark' ? 'btn-dark' : 'btn-light'}>
      Click Me
    </button>
  );
}

This is called prop drilling, and it has real costs:

  • Noise: MainLayout and Sidebar carry a prop they never use, and every new intermediate component has to forward it as well.
  • Fragility: renaming or reshaping the value means touching every component in the chain, even the ones that don't care about it.
  • Coupling: intermediate components become aware of data that is none of their business, making them harder to reuse elsewhere.

The Solution: Scopes

Retend provides Scopes for sharing data with descendants. A Scope makes a value available to every component below a Provider, so a child can read it directly without the intermediate components knowing it exists.

The same tree, without prop drilling:

function App() {
  const theme = 'dark';
  return (
    <ThemeScope.Provider value={theme}>
      <MainLayout />
    </ThemeScope.Provider>
  );
}

function MainLayout() {
  // No theme prop. No forwarding.
  return <Sidebar />;
}

function Sidebar() {
  return <ThemedButton />;
}

function ThemedButton() {
  // Reads the theme directly, no matter how deep it is.
  const theme = useScopeContext(ThemeScope);
  return (
    <button class={theme === 'dark' ? 'btn-dark' : 'btn-light'}>
      Click Me
    </button>
  );
}

Creating and Using Scopes

A Scope is created with createScope():

import { createScope } from 'retend';

// Give it a name to help with debugging
export const ThemeScope = createScope('Theme');

The returned Scope object contains a Provider component. The relevant layout is wrapped in the Provider, and the shared data is passed through its value prop:

import { ThemeScope } from './scopes';

function App() {
  const currentTheme = 'dark';

  return (
    <ThemeScope.Provider value={currentTheme}>
      <MainLayout />
    </ThemeScope.Provider>
  );
}

A component inside MainLayout can read the theme with useScopeContext() without receiving it from intermediate components:

import { useScopeContext } from 'retend';
import { ThemeScope } from './scopes';

function ThemedButton() {
  // Grab the data directly from the Scope
  const theme = useScopeContext(ThemeScope);

  return (
    <button
      type="button"
      class={[
        'btn',
        { 'btn-dark': theme === 'dark', 'btn-light': theme === 'light' },
      ]}
    >
      Click Me
    </button>
  );
}

If no Provider exists above a component that reads a Scope, Retend throws an error.

Passing Reactive Data (Cells)

Shared data can be reactive, such as login state or shopping cart contents. A Cell is passed to a Scope like any other value:

import { Cell, createScope, useScopeContext } from 'retend';

export const UserScope = createScope('User');

function App() {
  // 1. Create a reactive Cell
  const user = Cell.source({ name: 'Alice', role: 'guest' });

  return (
    // 2. Pass the entire Cell into the Provider
    <UserScope.Provider value={user}>
      <Dashboard />
    </UserScope.Provider>
  );
}

Child components can read the Cell from the Scope, with .get() reading its value and .set() updating it. Updates are reflected wherever the Cell is used.

function UserProfile() {
  // 3. Grab the Cell from the Scope
  const userCell = useScopeContext(UserScope);

  // 4. Create derived state from it
  const userName = Cell.derived(() => userCell.get().name);

  const upgradeRole = () => {
    // 5. Update the shared Cell
    const current = userCell.get();
    userCell.set({ ...current, role: 'admin' });
  };

  return (
    <div>
      <p>Hello, {userName}</p>
      <button type="button" onClick={upgradeRole}>
        Upgrade to Admin
      </button>
    </div>
  );
}

Scopes vs. Global Variables

Since a Scope is just shared data, it is tempting to skip the Provider entirely and export a module-level Cell instead:

// user.js
export const currentUser = Cell.source({ name: 'Alice', role: 'guest' });

// Anywhere else
import { currentUser } from './user.js';

function UserProfile() {
  const name = Cell.derived(() => currentUser.get().name);
  return <p>Hello, {name}</p>;
}

Skipping the Provider this way is discouraged. Module globals have problems that Scopes are designed to solve:

  1. One instance, ever. A module global is a singleton, so two instances of the same widget cannot be rendered on one page with independent state — both would read and write the same Cell. A Scope's state lives with its Provider, so each Provider on the page gets its own isolated copy.

  2. No lifecycle. A module global lives until the page is unloaded. If the part of the tree that owns the data is removed, the global silently keeps its state (and any listeners) around. Retend cleans up scoped data when the subtree containing the Provider is removed.

  3. Harder to test. Tests that import a module global share state between cases unless each test manually resets it. Tests that render a component under a fresh Provider start from a clean value every time.

  4. Hidden dependencies. A component that imports a global doesn't declare that it depends on shared state — the import is invisible in its signature. A component that calls useScopeContext(ThemeScope) makes its dependency explicit, and fails loudly if no Provider supplies the value.

Even for data that seems genuinely app-wide, a Scope is preferable: wrapping the root in the Provider once keeps the same convenience as a global while retaining isolation, cleanup, and testability.