Skip to main content

Building Configuration UIs with Solis React

This guide shows how to build a marketplace and a rich configuration experience in React with Solis React (@prismatic-io/solis-react), the React package of Solis, Prismatic's custom UI SDK. The code comes from the Headless Configuration page of the example embedded app, which you can run and read alongside this guide.

Install the SDK​

Install solis-react from the preview tag:

npm install @prismatic-io/solis-react@preview

solis-react supports React 18 and 19. It doesn't support server-side rendering: during a server render, every hook is loading.

Add the provider​

Wrap the part of your app that uses Prismatic in PrismaticProvider. Pass your Prismatic URL and an embedded user's JWT, signed by your backend as described in Authenticating embedded users:

App.tsx
import { PrismaticProvider, usePrismatic } from "@prismatic-io/solis-react";

export function Integrations({ token }: { token: string }) {
return (
<PrismaticProvider prismaticUrl="https://app.prismatic.io" auth={{ token }}>
<Marketplace />
</PrismaticProvider>
);
}

When your app refreshes the token, pass the new one. The provider applies it to the live session without starting over.

usePrismatic() returns the session. It's loading until the hidden frame connects and accepts the token:

const session = usePrismatic();
if (session.status === "loading") return <Spinner />;
if (session.status === "error")
return <p>Could not connect: {session.error.message}</p>;

Resources, actions, and permissions​

Every hook follows the same rules:

  • A hook returns a resource: { status: "loading" }, { status: "error", error }, or { status: "success", data }. Read data only after checking status.
  • Changes are actions on resource.actions. execute() resolves to a result, { status: "success", data } or { status: "error", error }, and never throws. An action also has its own status, so you can disable a button while it runs.
  • Each guarded action has a permission at data.permissions.<action>: { allowed: true }, or { allowed: false, reason }.

List the marketplace​

useMarketplace() lists the integrations the customer can activate. Each item is its own resource:

Marketplace.tsx
import { useMarketplace } from "@prismatic-io/solis-react";

function Marketplace() {
const marketplace = useMarketplace({ includeActiveIntegrations: true });
if (marketplace.status === "loading") return <Spinner />;
if (marketplace.status === "error") return <p>{marketplace.error.message}</p>;

return marketplace.data.items.map((item) =>
item.status === "success" ? (
<IntegrationCard
key={item.id}
name={item.data.name}
experience={item.data.configurationExperience}
instances={item.data.instances}
/>
) : null,
);
}

Each listing includes configurationExperience, headless or hosted, and instances, the customer's instances of that integration.

Activate an integration​

useMarketplaceIntegration(id) returns one listing. Its createInstance action creates an instance that isn't deployed yet:

const integration = useMarketplaceIntegration(integrationId);
if (integration.status !== "success") return null;
const { createInstance } = integration.actions;

return (
<button
disabled={
!integration.data.permissions.createInstance.allowed ||
createInstance.status === "loading"
}
onClick={() => createInstance.execute({ name: integration.data.name })}
>
Activate {integration.data.name}
</button>
);

Lists refresh on their own afterward, so the new instance appears in integration.data.instances.

Configure an instance​

Configuring an instance takes four steps: load the configuration, run init, save, and deploy.

Load the configuration​

useConfiguration({ instanceId }) returns the configuration of the version the instance runs. Its data includes the schema, the ui layout, configurationExperience, and serverFunctions:

const instance = useInstance(instanceId);
const configuration = useConfiguration({ instanceId });

ui is the layout the integration declares: { type: "uiSchema", value } with a JSON Forms layout, or { type: "custom", value } with a value your app defines. It's absent or null when the integration has no layout. To use a JSON Forms layout:

const { ui } = configuration.data;
const uiSchema = ui?.type === "uiSchema" ? ui.value : undefined;

Run init​

init returns the values the integration suggests. It saves nothing, so it's safe to run as soon as the form opens:

// What the example integration's init returns
interface InitProposal {
configuration: FakeCrmConfiguration;
unresolvedReasons?: string[];
}

const [proposal, setProposal] = useState<InitProposal | null>(null);

useEffect(() => {
if (configuration.status !== "success") return;
configuration.actions.init.execute({}).then((result) => {
if (result.status === "success") setProposal(result.data as InitProposal);
});
}, [configuration.status]);

init's result includes configuration, the suggested values, and userConfiguration when the integration asks each user for settings. The integration can add other properties for your app. The example integration adds unresolvedReasons. Use configuration as your form's starting values, and show any unresolvedReasons for the customer to review.

Prismatic doesn't validate init's suggestions against the schema. It validates the values your app saves.

Save and deploy​

save stores the customer's values. Prismatic validates them against the schema first. Then deploy makes the instance run with them:

const [fieldErrors, setFieldErrors] = useState<
readonly { path: string | null; message: string }[]
>([]);

const submit = async (value: FakeCrmConfiguration) => {
const saved = await configuration.actions.save.execute({ value });
if (saved.status === "error") {
// PRISMATIC_CONFIGURATION_INVALID lists each invalid field
setFieldErrors(saved.error.fields ?? []);
return;
}
await instance.actions.deploy.execute();
};

Saving never deploys. Until your app calls deploy, a deployed instance keeps running with its previous values, and its lifecycle is pendingChanges. A new instance stays notDeployed until its first deploy.

Flow schedules​

An integration can let whoever deploys an instance choose when a flow runs, by giving the flow schedule: { fromDeployer: true }. configuration.data.flows lists the flows of the version being configured, with each flow's id, name, stableId, and scheduleFromDeployer.

Pass schedules to save alongside the values:

await configuration.actions.save.execute({
value: formValues,
flows: [
{
flowId: selectedFlow.id,
schedule: { expression: "0 9 * * *", timezone: "America/Phoenix" },
},
],
});
  • An expression is a cron string, "once", or "none" to turn off scheduled runs. A missing or null timezone means UTC.
  • Omit flows, or a flow, to keep its current schedule. A flow that newly requires a schedule from the deployer needs one before the configuration is complete.
  • When saving an update, use the flow IDs of the version you're moving to. Schedules carry over for flows with the same stable ID.

The instance's data.flows includes each flow's saved schedule.

Render the form​

Your app can render a form generically from configuration.data.schema, or build a form for one integration. The example app's Fake CRM form is built for one integration. It still reads each field's label and whether it's required from the schema, so a new field in the integration appears without an app change.

Calling server functions​

useServerFunction(configuration, key) returns an action that runs one of the integration's server functions. Type its inputs and result with generics:

interface Field {
name: string;
label: string;
}

const listFields = useServerFunction<{ recordType: string }, Field[]>(
configuration,
"listFields",
);

const result = await listFields.execute({ inputs: { recordType: "Contact" } });
if (result.status === "success") setFields(result.data);

Things to know:

  • One call at a time. An action runs one call at a time. Calling execute() while a call runs resolves to a PRISMATIC_ACTION_BUSY error. To run the same function side by side, call useServerFunction more than once.
  • Results aren't validated. The generics type the result, but nothing checks it at runtime.
  • Unsaved values are inputs. A server function reads only saved configuration from its context. Pass the customer's unsaved choices as inputs.

To refetch a function whenever its inputs change, like a query, the example app uses a small hook, useServerFunctionQuery. It runs the function again with the newest inputs when a call finishes, so calls never overlap.

Connections​

configuration.data.connections is a resource that loads after the configuration. It lists the connections init and each server function need. Each requirement has a key, a label, and options, the customer's existing connections that can fill it:

const { connections } = configuration.data;
if (connections.status === "success") {
for (const requirement of connections.data.init) {
console.log(
requirement.key,
requirement.options.map((option) => option.id),
);
}
}

Keep the customer's choices as { key, id } selections, and pass them to init and server functions:

await configuration.actions.init.execute({ connections: selections });
await listChannels.execute({ inputs: { search }, connections: selections });

A requirement backed by a customer-activated connection can create a new connection with requirement.actions.createConnection. For OAuth 2.0, it opens the provider's consent screen, so call it directly from a click handler.

Routing between headless and hosted configuration​

Integrations that use config pages report a configurationExperience of hosted. For those, render HostedConfiguration, which opens Prismatic's config wizard:

import { HostedConfiguration } from "@prismatic-io/solis-react";

if (configuration.data.configurationExperience === "hosted") {
return (
<HostedConfiguration
instanceId={instanceId}
className="h-full"
onClose={close}
/>
);
}
return <MyConfigurationForm configuration={configuration} />;

After the wizard changes something, the SDK refreshes instances, the marketplace, and configurations, so the rest of your UI updates.

Updates​

An instance's data.update is null when the instance is on the newest version. Otherwise it's { integrationVersionId, versionNumber, requiresReconfiguration }:

  • When requiresReconfiguration is false, the configuration's shape didn't change. Call upgrade, then deploy:

    const moved = await instance.actions.upgrade.execute();
    if (moved.status === "success") await instance.actions.deploy.execute();
  • When requiresReconfiguration is true, load the new version's configuration and configure it as usual. Saving it moves the instance to the new version:

    const configuration = useConfiguration({
    instanceId,
    integrationVersionId: instance.data.update?.integrationVersionId,
    });

    After you save, the instance has no update, so remember which version is under review until the customer finishes.

See Configuration versions and init for how integration authors decide which kind of update they publish.

Instance status​

An instance's data.lifecycle summarizes its status:

LifecycleMeaning
notDeployedCreated, but never deployed
needsReconfigurationThe instance needs its configuration reviewed
needsUserConfigurationThe current user needs to save their user-level settings
pausedPaused
pendingChangesSaved changes haven't been deployed
activeRunning

The instance's actions include pause, resume, and remove, each with a matching permission.

Next steps​