Ajo

Context

Share values down a component tree with context().

Some values belong to a whole part of the page: a theme, the signed-in person, the notebook being edited. Passing them as args through every level in between is noise. A context lets a stateful component provide a value that any component below it can read, however deep.

Create a context

context(fallback) from ajo/context returns a function. Call it without arguments to read the current value; call it with a value to write one. A write returns the value it wrote. The fallback is what a read returns when no component above has written a value.

src/contexts.ts
import { context } from 'ajo/context'

export type Theme = 'paper' | 'night'

export const ThemeContext = context<Theme>('paper')
export const ReaderContext = context<{ name: string } | null>(null)

The context function holds no value itself. Values live in the components that write them, so a context defined at module level can be shared by any number of trees, including separate server renders.

Who reads and who writes

ComponentReadsWrites
StatelessYesNo
StatefulYes, inside the loopYes: before the loop for a constant value, inside it for one that changes

A stateless component has no context of its own. It reads from the stateful component that is rendering it, and that is all it should do.

A stateful component reads inside its loop, so every render sees the current value. A read placed before the loop runs once and keeps the value of the first render forever. Where the write goes depends on the value: one that never changes can be written once, before the loop, while one that depends on state is written inside the loop, before the yield, so descendants see it when they render.

TSX
// Constant: written once, before the loop.
const PrintView: Stateful<WithChildren> = function* () {
  ThemeContext('paper')
  for (const { children } of this) yield children
}

// Dynamic: written on every render, inside the loop.
const Reader: Stateful<WithChildren<{ name: string }>> = function* () {
  for (const { name, children } of this) {
    ReaderContext({ name })
    yield children
  }
}

A theme provider

The provider keeps the theme as state, writes it on every render and renders its children below a toggle. Readers can be stateless or stateful.

src/theme.tsx
import type { Stateful, Stateless, WithChildren } from 'ajo'
import { ThemeContext, type Theme } from './contexts'

export const ThemeProvider: Stateful<WithChildren> = function* () {

  let theme: Theme = 'paper'

  const toggle = () => this.next(() => theme = theme == 'paper' ? 'night' : 'paper')

  for (const { children } of this) {

    ThemeContext(theme) // write: every component below reads this value

    yield (
      <>
        <button set:onclick={toggle}>{theme == 'paper' ? 'Night' : 'Paper'} theme</button>
        {children}
      </>
    )
  }
}

// Stateless: read only.
export const EntryTitle: Stateless<{ title: string }> = ({ title }) => (
  <h2 class={`title title-${ThemeContext()}`}>{title}</h2>
)

// Stateful: read inside the loop.
export const Margin: Stateful = function* () {
  while (true) {
    const theme = ThemeContext()
    yield <aside class={`margin margin-${theme}`}>Notes in the margin</aside>
  }
}
TSX
<ThemeProvider>
  <EntryTitle title="Morning pages" />
  <Margin />
</ThemeProvider>

Clicking the button renders the provider again with the new theme. Its children render as part of that render and read the new value.

How values flow

When a stateful component first renders, it gets a context scope of its own, linked to the scope of the stateful component rendering it: its nearest stateful ancestor in the tree. From there:

  • A write stores the value in the writer’s own scope. Only the writer and the components below it see it; siblings and ancestors do not.
  • A read looks in the reader’s scope, then in each ancestor’s, and returns the first value it finds, or the fallback. A component that writes a context and then reads it gets its own value.
  • A read returns what is there at the moment it runs. A descendant sees a new value the next time it renders, which is usually right away, because a provider’s render includes its subtree. Parts skipped with memo or skip keep what they read last until they render again.

Resetting a provider with return() and next() runs its code from the top again, so its writes start over from its initial state as well.

Types and several contexts

Every call to context() creates an independent context, so a component can read and write as many as it needs without them interfering. The type parameter covers both directions: a read returns T, and a write accepts a value of type T.

TSX
const Header: Stateful = function* () {
  while (true) {
    const theme = ThemeContext()     // 'paper' | 'night'
    const reader = ReaderContext()   // { name: string } | null
    yield <header class={theme}>{reader ? `Hello, ${reader.name}` : 'Welcome'}</header>
  }
}

The fallback is optional in the signature, but a read outside every provider returns it, so give each context a fallback of its own type. For a value that only exists under a provider, include null in the type and use it as the fallback, as ReaderContext does; readers then handle the missing case explicitly.

On the server

Context works the same way with ajo/html. Each stateful component gets a scope during server rendering too, and since a server render runs every component once, a provider’s first value is the one its descendants render. See Server rendering.

Source of truth: README.md in cristianfalcone/ajo