January 30, 2026 · 5 min read★ Featured
Building on a dynamic StreamField renderer, this post focuses on adding interactivity inside content blocks themselves. It introduces patterns for shared state and inline behavior that will later enable more advanced layout and recursive rendering strategies.
In the previous post, we built a dynamic renderer that transforms Wagtail StreamField JSON into predictable, reusable React components. That work solved a critical foundational problem: getting structured content onto the page without hardcoding layouts or templates.
This article focuses on the next evolution of that renderer: adding interactivity inside rendered blocks. Instead of treating StreamField output as static HTML, we’ll explore how features like copy buttons and image lightboxes can live naturally inside a StreamField‑driven architecture.
The guiding principle remains the same as before: keep content declarative, behavior cohesive, and pages easy to reason about as they grow.
In this architecture, interactive behavior lives inside the same renderer file that maps block types to UI. Rather than splitting logic across many small components, the BlocksRenderer acts as a client‑side coordinator responsible for both rendering and shared UI state.
export function BlocksRenderer({ blocks }) {
const [lightbox, setLightbox] = useState<string | null>(null);
const [copiedCode, setCopiedCode] = useState<number | null>(null);
}
By colocating rendering and interaction state, the renderer can respond to user actions across blocks without prop drilling, context providers, or duplicated logic. This keeps the mental model simple: if something happens inside StreamField content, it’s handled here.
Keeping interactive blocks in a single renderer is not just a convenience; it’s a structural choice that improves clarity as a project grows.
This approach provides several practical benefits:
"use client")Most importantly, it aligns with how content pages actually behave. Blocks are rarely independent widgets; they are contextual pieces of a larger narrative. Centralizing interactivity respects that reality.
To see these ideas in everyday terms, imagine a cooking blog. Each recipe is composed of multiple blocks:
Using a BlocksRenderer with inline interactivity:
A common enhancement for content‑heavy pages is adding a copy‑to‑clipboard button to code snippets rendered from markdown. This is especially valuable for documentation, tutorials, and technical blog posts, where readers frequently reuse snippets.
At the page level, nothing changes. The page component stays focused on layout and data fetching, delegating all content concerns to the renderer:
// app/blog/[slug]/page.js
import { BlocksRenderer } from "@/components/BlocksRenderer";
export default async function BlogPost({ params }) {
const response = await fetch(
`https://api.example.com/pages/${params.slug}/`
);
const data = await response.json();
return (
<article className="max-w-4xl mx-auto px-4 py-12">
<header className="mb-12">
<h1 className="text-4xl font-bold mb-4">{data.title}</h1>
<time className="text-gray-500">{data.publishedDate}</time>
</header>
<BlocksRenderer blocks={data.body} />
<footer className="mt-12 pt-8 border-t">
<p>Written by {data.author}</p>
</footer>
</article>
);
}
Inside the BlocksRenderer, markdown blocks are rendered using ReactMarkdown. The copy button is injected directly into the pre renderer, colocated with the code it acts on.
<ReactMarkdown
components={{
pre({ children }) {
return (
<div className="relative group">
<button
onClick={() => copyToClipboard(codeText, i)}
className="absolute right-2 top-2 text-xs"
>
{copiedCode === i ? "Copied" : "Copy"}
</button>
<pre>{children}</pre>
</div>
);
},
}}
>
{block.value}
</ReactMarkdown>
This interaction works cleanly because the renderer already has full context:
Extracting a separate component here would introduce indirection without adding clarity. Inline rendering keeps behavior obvious and easy to follow.
Images interactivity
Image blocks are another natural candidate for interactivity. Rather than embedding modal logic inside each image block, the renderer manages a single shared lightbox for the entire page.
Once again, the page component stays unchanged:
<BlocksRenderer blocks={data.body} />
Each image block simply signals intent when clicked:
onClick={() => setLightbox(imgSrc)}
The renderer owns the modal itself:
<Dialog open={!!lightbox} onOpenChange={() => setLightbox(null)}>
{lightbox && (
<Image src={lightbox} alt="" fill className="object-contain" />
)}
</Dialog>
This separation keeps blocks declarative while allowing the renderer to enforce consistent UX rules. Blocks say what should happen; the renderer decides how it happens.
Lightbox example. Click to see effect.
Adopt a single, client‑side BlocksRenderer that owns interactive state and renders StreamField blocks inline by default.
This decision reflects how content pages behave in practice:
Extracting standalone block components remains a valid alternative when a block:
Extraction is treated as an intentional optimization, not a default starting point.
This choice results in renderer files that may grow over time, but remain predictable and cohesive. Complexity is visible rather than distributed, and refactoring is driven by actual reuse rather than speculation.
Reach out via the contact form or connect on Linkedin!