decompose-jsx-to-atomics
When to use
Break an existing React component (often a whole page in one file) into Layer-1 atomics, Layer-2 containers and Layer-3 Sections, so its parts become reusable instead of one monolithic block.
Use when the customer HAS code and a component is too coarse to reuse: a page-level component, a "section" that renders six unrelated things, or anything you'd otherwise register whole. Phrases: "make this component reusable", "break this page into sections", "my whole page is one component", "decompose this React component", "this section is too big", "why can't I reuse this". Run BEFORE discover-sections on an existing codebase, and before register-component on any component larger than one content field. Do NOT use for a design artifact with no code (that's decompose-design) or to derive a schema from a component's existing props (that's design-section-from-jsx).
Mandatory auth preflight: settle the credential before the first API call. Resolve it OAuth-first per
authenticate-cma:CS_OAUTH_ACCESS_TOKEN, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. Never ask the user for a sessionauthtoken. If nothing resolves, or a refresh fails with400 invalid_refresh_token, hand them! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth(it needs a TTY and a browser, so it cannot be run for them) and wait.403 error_code 316is a valid credential aimed at another org: fix the org or theapi_key, do not re-authenticate.
Reuse preflight: mandatory when the project already has compositions. Before naming a single new atomic, component or Section, run
match-existing-pattern§ Step 1b: it groups existing Sections by the schema they bind and ranks the component palette by real usage. Decomposition is where reuse has to bite. Once this step namesHeadinginstead of the project'salpha-atom-heading, the duplicate survives into the plan and into the build. Mark every row reuse, extend, or new, and never new without having looked.
Decompose existing JSX into atomics and Sections
Context
The failure this exists to prevent. With an existing codebase, nothing used to look inside a component. discover-sections counts how many routes use a component name. design-section-from-jsx reads its props. So a page written as one <RewardsPage> produced one registered component and one Section covering the whole page. The consequences compound:
| Symptom the customer reports | Actual cause |
|---|---|
| "Nothing is reusable. I can't put the hero on another page" | The hero was never a separate piece, it's markup inside a page component |
| "Authors can only change text, not rearrange anything" | One Section = one block on the canvas. Nothing to reorder |
| "We never used slots / repeaters / exposed props" | A monolith has no seams, so those primitives never come up |
| "The data doesn't bind properly to entries" | One coarse prop takes a whole object, often typed any, which the binder then flattens (see register-component § The any flatten trap). Atomic props bind one field each and can't flatten |
An existing React component is not a reason to skip decomposition. It's the input to it: a component becomes a Section by being broken up, and its parts become the reusable pieces.
Taxonomy (Layer-1 atomic / Layer-2 container / Layer-3 Section) is defined once in components-sections-and-templates and applied to visual input by decompose-design. This skill applies the same taxonomy to code. Don't re-derive the layers here. Read them there.
Task
-
Read the render tree, not the prop list. Open
componentPathand walk what it returns. Props tell you what the current caller passes. The render tree tells you what the component actually contains. That's where the reusable pieces are hiding. Note for each element: tag/component, what content it renders, and where that content comes from (a prop, a literal, a.map(), a fetch). -
Classify every leaf as a Layer-1 atomic, one piece of content each.
In the JSX Atomic Prop type <h1>,<h2>, heading, title, labelHeading/Textstring<p>of plain copy, no formatting possibleText/Paragraphstring<img>, background imageImageimageurl<a>, button with a destinationLink/Buttonhref(+stringlabel)Formatted copy: dangerouslySetInnerHTML, a markdown renderer,ReactMarkdown,<div>holding<strong>/<ul>/<a>from data, anything an author can styleRichTextjson_rte(JSON RTE) orany(HTML RTE / Markdown)Copy that truncates, "read more", accordion body, <details>, show/hideCollapsibleTextas above Formatted copy that can embed a card or media mid-flow RichText+registerRTERendererwithembeddedEntry/embeddedAssetas above A date, price, count Textdatestring/numberAn interactive control (form field, toggle, carousel arrow) stays inside a registered component: built-ins can't express behavior - On an existing codebase, flag the upgrade: don't perform it. Code that renders prose through a plain
<p>is often just what was quickest to write. Two cases, very different costs:- The CT field is already rich text and the code renders it plainly, so switching to
RichTextis free: no schema change, and it fixes markup currently showing as literal characters. - The field is plain text holding prose, so suggest
json_rte, state that it is a schema change on live entries (migrate-ct-schema), and proceed with plain text if the user declines.
Say it as a recommendation with the cost attached, then respect the answer. Never migrate a field as a side effect of decomposing a component.
Deciding plain vs rich: ask whether an author could ever want bold, a link, a list, or an embed in that copy. If yes it is rich text.
Text/ParagraphrenderString(text)and would show the markup as literal<p>characters. Body copy, descriptions and long-form prose are rich text far more often than they look. Headings and labels almost never are. Mapping:register-json-rte§ Which component for which field.A hardcoded literal is still an atomic.
<h1>Join our rewards</h1>becomes astringatomic bound to a CT field. That's precisely how the author gains the ability to edit it. Leaving literals in code is the single biggest source of "why can't I change this text". - The CT field is already rich text and the code renders it plainly, so switching to
-
Classify wrappers as Layer-2 containers with slots. A
<div>/<section>that exists for layout (grid, two-column, card shell, band with a background) becomes a registered component with aslotprop, not a hardcoded parent. The layout stays in your code (where the design system lives). What goes inside becomes author-controlled. Seeuse-section-slot. -
Every
.map()is a list, but check WHAT it iterates before calling it a Repeater. Two different answers, and getting it wrong models runtime data as content:The .map()iteratesIt is Skill A CMS field on the entry: Modular Blocks, multi-Reference, multi-Group, scalar list ( entry.modular_blocks.map(…))Repeater bound to that field, item shape as its own child Section build-repeating-section,use-repeaterItems whose type varies per entry (Modular Blocks, multi-CT references) Repeater + a Condition Block per type use-condition-blockRuntime / external data: a fetch result, search results, cart contents, anything in useState(searchResults.map(…))Not a Repeater and not a CMS field. The component owns it. Feed it through the datapropwire-external-dataModeling runtime results as a CMS field is a real failure mode, not a harmless mismatch. Flight-search results or an API response have no entry to bind to. You'd be creating content fields nobody can populate. Test: could an author edit these items in Contentstack? If no, it's external data.
Iterating with a fixed index (
items[0],items[1],items[2]) over a CMS field is the Repeater case wearing a disguise. Decompose it as a Repeater, not three atomics.A partially-handled block list is a bug worth surfacing. Code like
if (block.hasOwnProperty("marquee")) return <Marquee …/>renders nothing for every other block type, silently. Enumerate the CT's block types and give each a Condition Block, or state explicitly that the others are unsupported. -
Draw Layer-3 Section boundaries: one per recognizable page part. A Section is a hero, a form band, a feature grid, a CTA strip. One Section for a whole page is a defect, not a shortcut. If your boundary list has one entry and the page has visibly distinct regions, go back to step 1.
Boundary test, in order:
- Could a person point at it and name it? If yes, it is its own Section.
- Could it plausibly appear on another page? If yes, it is definitely its own Section.
- Does it read from a different part of the entry (a group, a reference, a block list)? If yes, it is its own Section, scoped to that field.
-
Dedupe against what already exists. Before proposing anything new, check the codebase and Studio for pieces that already cover it:
grep -rl "<Heading\|<Text\|<Image" srcfor atomics already registered, plus the Studio-side reuse inventory,match-existing-pattern§ Step 1b, which groups existing Sections bylinked_schemasand ranks the component palette by real usage. Both halves, not just the grep. The codebase says what is registered. Only the project says what is used, and an atom nothing uses is not a convention to build on. Two Sections that differ only in spacing are one Section with an exposed prop, not two. -
Emit the binding map: one atomic prop, one CT field. This is what fixes the binding complaints:
Hero band (Section, scoped to entry.hero group)Image image → entry.hero.image (imageurl)Heading headline → entry.hero.headline (string)Text body → entry.hero.body (string)Link cta → entry.hero.cta.href (href)A single prop taking the whole object is the failure mode, not a shortcut.
content: { type: "any" }fed theherogroup renders blank. The binder recursively unwraps single-key objects for every type exceptobject/array. Bind field-by-field. Useobject/arrayonly when the component genuinely consumes a whole group or list. -
Print the breakdown, then hand off.
DECOMPOSITION — <component path>Was: 1 component rendering the whole page.Now: <N> Sections · <M> Layer-1 atomics · <K> Layer-2 containersSections (build in this order):1. <Hero band> scope: entry.hero reusable on: any page with a heroatomics: Image, Heading, Text, Link2. <Offers list> scope: entry.offers Repeater + child Section per offeratomics (child): Image, Heading, Text3. <Signup form> stays one registered component — interactiveReused, not rebuilt:<Heading / Text / Image already registered — no work>New CT fields needed:<entry.hero.headline (string), entry.hero.image (file), …>Then run
register-componentfor each atomic and container not already registered,design-section-from-jsxif a Section's linked schema doesn't exist,build-sectionper Section in the printed order, andbuild-repeating-sectionfor any list. If a plan is in flight, feed this breakdown intoplan-studio-architectureStep 2 rather than building straight away.
Worked example: a page component becomes four reusable pieces
// Before — one component, nothing reusable
export function RewardsPage({ data }) {
return (
<main>
<section className="hero">
<img src={data.heroImage} />
<h1>Join our rewards</h1> {/* hardcoded */}
<p>{data.heroBody}</p>
<a href={data.ctaHref}>Sign up</a>
</section>
<div className="grid">
{data.offers.map((o) => (
<article key={o.id}><img src={o.icon} /><h3>{o.title}</h3><p>{o.text}</p></article>
))}
</div>
<RewardsSignupForm categories={data.categories} />
</main>
);
}
| Piece | Layer | Becomes |
|---|---|---|
<img>, <h1>, <p>, <a> in .hero | 1 | 4 atomics, one CT field each, including the hardcoded <h1>, now editable |
.hero wrapper | 2 | Hero band container (layout stays in code) |
.grid + .map() | 3 | Offers list = Repeater over entry.offers + child Offer card Section |
<article> internals | 1 | 3 atomics on the child Section, reusable wherever a card appears |
RewardsSignupForm | 2 | Stays one registered component: interactive, plus an action prop so an author picks the submit behavior |
Result: 3 Sections (Hero band, Offers list, Signup form) + 1 child Section (Offer card), 7 atomics, all reusable on any future page. Before: one block an author could not rearrange, reuse, or restyle.
Every atom is born with its CSLP tags: not retrofitted
An atom that renders correctly but carries no data-cslp is invisible to Visual Editor: authors can see it and cannot click it. Nothing errors, so it ships. A real audit of 19 atomic components found 0 carrying the node-level tag.
So write the tags into the atom as you create it. This is the shape for every atom this skill produces, two attribute bags, always both:
// The template. `studioAttributes` on the root, one `$`-twin per bindable prop.
type Props = {
headline?: string;
$headline?: Record<string, string>; // injected by the SDK, never built by hand
studioAttributes?: Record<string, string>;
};
export default function AtomHeading({ headline, $headline, studioAttributes, ...rest }: Props) {
return (
<h2 {...rest} {...studioAttributes} {...$headline}>
{headline}
</h2>
);
}
| Bag | Spread on | Without it |
|---|---|---|
studioAttributes | the atom's root element | the node cannot be selected on canvas or in VB |
$<prop> | the element rendering that prop | that field has no inline click-to-edit |
Three rules that follow, and they are not optional:
- One root element per atom. A fragment (
<>…</>) has nowhere to putstudioAttributes. If an atom genuinely cannot have a single root, register it withwrapinstead. - Root only. Spreading
studioAttributeson an inner element makes Studio measure the wrong box, so selection and drop-targeting land in the wrong place. - Never hand-build a
$-twin. It arrives as a prop when the field is bound. Constructing one yourself produces a tag pointing at nothing.
The full contract, the wrap alternative and the cslp: { appendTags: true } prerequisite live in register-component § The studioAttributes contract. Do not restate them, but do not ship an atom without them either.
Inputs needed from the user
componentPath: the component to decompose. If they point at a route file, decompose the page component it renders.entryShape: the content type / entry the page reads from, if it exists. Without it, propose the CT fields in step 7 and hand them todesign-section-from-jsx.- Nothing else. Don't ask which parts to split. Propose the breakdown and let them correct it.
Acceptance
- The render tree was read, not just the prop list.
- Every atomic component carries its CSLP tags:
studioAttributesspread on the root element andwrap: falsein its register entry and each bindable prop's$-twin spread on the element rendering it. Verified in the DOM, not assumed: the root and each bound text/image element must showdata-cslp. Without this the atom renders correctly but is invisible to Visual Editor and Live Preview. Seeregister-component§ ThestudioAttributescontract and § The$-twin contract. - Every leaf is an atomic with exactly one content field, hardcoded literals included.
- Every
.map()was classified before being converted: a CMS field becomes a Repeater (+ Condition Block for varying types), runtime or external data becomes thedataprop, not a Repeater. No runtime list was modelled as a CMS field. - Partially-handled block lists were surfaced: every block type has a Condition Block, or the unsupported ones are stated.
- More than one Section, unless the page genuinely has one region, and if it's one, that's stated with a reason.
- Every atomic prop binds to exactly one CT field. No prop takes a whole object except a deliberate
object/array. - Existing atomics and Sections were reused, not duplicated.
- Interactive parts stayed inside a registered component, with
actionprops where an author should choose behavior.
Common pitfalls
| Pitfall | Why it bites | Fix |
|---|---|---|
| Registering the page component whole because "it's used on one route" | Reuse count is a prioritization signal, not a qualification gate. A one-off page component is the strongest decomposition candidate. It's where the monolith is | Decompose first, then register the parts. See discover-sections § scoring |
| Reading props instead of the render tree | Props describe today's caller. The reusable pieces are in what the component renders | Walk the JSX return value |
| Leaving hardcoded copy in code | Authors then can't change it and file it as a bug. "Why can't I edit this heading" is almost always a literal that was never made an atomic | Every literal string/image becomes an atomic bound to a CT field |
.map() kept as a hardcoded list | Content can't grow, adding a fourth offer needs a developer | Repeater + child Section (+ Condition Block for blocks/references) |
Turning a runtime .map() into a Repeater: search results, a fetch response, useState contents | There's no entry behind it, so you create CMS fields nobody can populate and the Repeater binds to nothing | Ask "could an author edit these items in Contentstack?" If no, use wire-external-data's data prop |
| Converting a block list that only handles one block type | The other types render nothing today and will keep rendering nothing, the gap just moves into Studio | Enumerate the CT's block types. One Condition Block each, or say which are unsupported |
| One Section for the whole page | Nothing to reorder, nothing reusable, and the author never meets slots or repeaters | One Section per recognizable region. One-Section pages need a stated reason |
One coarse prop taking the whole object (type: "any") | The binder recursively unwraps single-key objects for every type except object/array, the component renders blank with no error | Bind field-by-field. object/array only when the component truly consumes a group or list. See register-component § The any flatten trap |
| Splitting on styling differences | Two "different" heroes that differ by padding become two Sections to maintain | One Section + an exposed prop, see expose-section-props |
See also
components-sections-and-templates: the Layer-1/2/3 taxonomy this appliescomponents-sections-and-templates: the Layer-1/2/3 taxonomy this appliesdecompose-design: same taxonomy, visual input instead of codediscover-sections: which components recur across routes. Run this after decompositiondesign-section-from-jsx: derive a Section's linked schema once boundaries are drawnregister-component: register each atomic and container. Prop types and theanytrapbuild-repeating-section·use-section-slot·expose-section-props