Interactive client components

Developer preview

This feature is in developer preview. Use with caution as it may change unexpectedly. For more information, contact us.

Goal: add a component that reacts to the visitor, for example a button that opens a panel. Send as little JavaScript to the browser as possible.

  • A Next.js app with the SDK. Refer to Next.js App Router SDK.
  • For the rich text example, the @uniformdev/richtext package at the same version as the SDK.
PatternUse it when
A server component with a client child (recommended)Only a small part of the component is interactive. You want visual editing for the text.
A client component in the component mapThe full component is interactive, and it is small.

The component in the component map stays a server component. It reads the parameters, and gives plain values to a small client component.

components/disclosure.tsx

"use client"; import { type ReactNode, useState } from "react"; type DisclosureProps = { defaultOpen: boolean; summary: ReactNode; children: ReactNode; }; export const Disclosure = ({ defaultOpen, summary, children }: DisclosureProps) => { const [open, setOpen] = useState(defaultOpen); return ( <div className="border-b py-2"> <div className="flex items-center justify-between gap-4"> <h3 className="font-semibold">{summary}</h3> <button type="button" aria-expanded={open} onClick={() => setOpen(!open)}> {open ? "Hide" : "Show"} </button> </div> {open ? <div className="pt-2">{children}</div> : null} </div> ); };

The client component knows nothing about Uniform. It gets a boolean and two React nodes.

components/faq-item.tsx

import { type ComponentParameter, type ComponentProps, UniformRichText, UniformText, } from "@uniformdev/next-app-router/component"; import type { ParameterRichTextValue } from "@uniformdev/richtext"; import { Disclosure } from "./disclosure"; type FaqItemParameters = { question?: ComponentParameter<string>; answer?: ComponentParameter<ParameterRichTextValue>; openByDefault?: ComponentParameter<boolean>; }; export const FaqItem = ({ parameters: { question, answer, openByDefault }, component, context, }: ComponentProps<FaqItemParameters>) => ( <Disclosure // Plain values only: a boolean and React elements. defaultOpen={openByDefault?.value === true || context.isContextualEditing} summary={ question ? <UniformText component={component} parameter={question} placeholder="Enter a question" /> : null } > {answer ? <UniformRichText component={component} parameter={answer} placeholder="Enter an answer" /> : null} </Disclosure> );

Register FaqItem in components/resolveComponent.tsx.

FaqItem makes the UniformText and UniformRichText elements on the server. It gives them to Disclosure as props. Thus authors can still edit the text in Canvas. In Canvas, context.isContextualEditing is true, so the panel is open and the author can see the answer.

You can also put "use client" in the file of a registered component:

components/promo-code.tsx

"use client"; import type { ComponentParameter, ComponentProps } from "@uniformdev/next-app-router/component"; import { useState } from "react"; type PromoCodeParameters = { label?: ComponentParameter<string>; code?: ComponentParameter<string>; }; export const PromoCode = ({ parameters: { label, code } }: ComponentProps<PromoCodeParameters>) => { const [visible, setVisible] = useState(false); if (!code?.value) return null; return ( <div className="rounded border p-4"> <p>{label?.value ?? "Your discount code"}</p> {visible ? ( <code className="text-xl">{code.value}</code> ) : ( <button type="button" onClick={() => setVisible(true)}> Show the code </button> )} </div> ); };

Register it in the component map as usual. components/resolveComponent.tsx stays a server file:

components/resolveComponent.tsx

import type { ResolveComponentFunction, ResolveComponentResult } from "@uniformdev/next-app-router"; import type { ComponentProps } from "@uniformdev/next-app-router/component"; import { FaqItem } from "./faq-item"; import { InterestCard } from "./interest-card"; import { PromoCode } from "./promo-code"; const componentMap: Record<string, ResolveComponentResult["component"]> = { faqItem: FaqItem, interestCard: InterestCard, promoCode: PromoCode, }; const NotFound = ({ type }: ComponentProps) => <div>Component not found: {type}</div>; export const resolveComponent: ResolveComponentFunction = ({ component }) => ({ component: componentMap[component.type] ?? NotFound, });

The SDK renders each component on the server. For a client component, React must serialize the props and send them to the browser. Props of a client component must be serializable. For example, plain objects, arrays, strings, numbers, booleans, null and React elements are serializable. Functions are not serializable.

ComponentProps obeys this rule:

PropContents
type, variantStrings
parametersThe parameter data from Uniform, plus parameterId
slotsFor each slot: its name, and for each item its _id, its variant data and the rendered child as a React element
componentIDs, the slot name and the slot index
contextThe composition ID and type, the state, isContextualEditing, matchedRoute, dynamicInputs and pageState

The SDK also renders its own client components with these props, for example the component for personalizations.

warning

The browser gets all props of a client component, also the parameters that it does not use. Do not keep secret values in parameters of a client component. A server component with a client child (pattern 1) sends only the values that you give to the child.

useQuirks and useScores give the data of the visitor in a client component. The component renders again when the data changes:

components/visitor-greeting.tsx

"use client"; import { useQuirks, useScores } from "@uniformdev/next-app-router/component"; export const VisitorGreeting = () => { const quirks = useQuirks(); const scores = useScores(); const country = quirks["vc-country"]; const isDeveloper = (scores?.developer ?? 0) > 50; return ( <p> {country ? `Welcome, visitor from ${country}.` : "Welcome."} {isDeveloper ? " Read our API docs." : null} </p> ); };

On the server, useQuirks returns an empty object, and useScores returns undefined. Thus the server HTML always shows "Welcome.". When the browser Context is ready, the component renders again with the data of the visitor. Show a neutral default in the server HTML.

The vc-country quirk comes from the Vercel geolocation headers. Refer to Personalization and A/B tests.

tip

To show different content to different visitors, use a personalization in Uniform. The SDK renders the variants for you. Use the hooks only for small changes that do not need a personalization.

useUniformContext gives the browser Context. Call context.update to change the data of the visitor. Personalizations on the page then select their variants again.

components/member-button.tsx

"use client"; import { useUniformContext } from "@uniformdev/next-app-router/component"; export const MemberButton = () => { const { context } = useUniformContext(); return ( <button type="button" disabled={!context} onClick={() => context?.update({ quirks: { member: "yes" } })}> I am a member </button> ); };

Make the quirk member in Uniform, so that personalization criteria can use it.

The server component reads the enrichment from its parameters. It gives the values as strings to the client button:

components/interest-button.tsx

"use client"; import { useUniformContext } from "@uniformdev/next-app-router/component"; type InterestButtonProps = { label: string; category: string; value: string; }; export const InterestButton = ({ label, category, value }: InterestButtonProps) => { const { context } = useUniformContext(); return ( <button type="button" disabled={!context} onClick={() => context?.update({ enrichments: [{ cat: category, key: value, str: 10 }] })} > {label} </button> ); };

components/interest-card.tsx

import type { ComponentParameter, ComponentProps } from "@uniformdev/next-app-router/component"; import { InterestButton } from "./interest-button"; type InterestCardParameters = { title?: ComponentParameter<string>; buttonLabel?: ComponentParameter<string>; enrichmentCategory?: ComponentParameter<string>; enrichmentValue?: ComponentParameter<string>; }; export const InterestCard = ({ parameters: { title, buttonLabel, enrichmentCategory, enrichmentValue }, }: ComponentProps<InterestCardParameters>) => ( <div className="rounded border p-4"> <h3>{title?.value}</h3> {enrichmentCategory?.value && enrichmentValue?.value ? ( <InterestButton label={buttonLabel?.value ?? "I am interested"} category={enrichmentCategory.value} value={enrichmentValue.value} /> ) : null} </div> );

cat is the public ID of an enrichment category in Uniform. key is the public ID of a value in this category. str is the score to add.

  • context can be undefined: useUniformContext returns undefined until the browser Context is ready. The examples disable the button until then.
  • Keep client components small: "use client" makes the component and all components that it imports client components. Put only the interactive part in a client component. Refer to Client-side context.
  • Enrichment tags on components: if an author adds enrichment tags to a component in Canvas, the SDK updates the scores for you. You do not need code for this.