# 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](https://prismatic.io/docs/embed/headless.md), Prismatic's custom UI SDK. The code comes from the **Headless Configuration** page of the [example embedded app](https://github.com/prismatic-io/embedded/tree/main/examples/example-embedded-app), which you can run and read alongside this guide.

## Install the SDK[​](#install-the-sdk "Direct link to Install the SDK")

Install `solis-react` from the `preview` tag:

```bash
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[​](#add-the-provider "Direct link to 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](https://prismatic.io/docs/get-started/embedded-marketplace/authenticate-embedded-users.md):

App.tsx

```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:

```tsx
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[​](#resources-actions-and-permissions "Direct link to 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[​](#list-the-marketplace "Direct link to List the marketplace")

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

Marketplace.tsx

```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[​](#activate-an-integration "Direct link to Activate an integration")

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

```tsx
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[​](#configure-an-instance "Direct link to Configure an instance")

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

### Load the configuration[​](#load-the-configuration "Direct link to 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`:

```tsx
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:

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

```

### Run init[​](#run-init "Direct link to Run init")

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

```tsx
// 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-and-deploy "Direct link to Save and deploy")

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

```tsx
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[​](#flow-schedules "Direct link to 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:

```tsx
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[​](#render-the-form "Direct link to 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](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/components/headless/fake-crm-configuration-form.tsx) 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[​](#calling-server-functions "Direct link to Calling server functions")

`useServerFunction(configuration, key)` returns an action that runs one of the integration's [server functions](https://prismatic.io/docs/integrations/headless-configuration/server-functions.md). Type its inputs and result with generics:

```tsx
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`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/hooks/use-server-function-query.ts). It runs the function again with the newest inputs when a call finishes, so calls never overlap.

## Connections[​](#connections "Direct link to 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:

```tsx
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:

```tsx
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[​](#routing-between-headless-and-hosted-configuration "Direct link to 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:

```tsx
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[​](#updates "Direct link to 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`:

  ```tsx
  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:

  ```tsx
  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](https://prismatic.io/docs/integrations/headless-configuration/configuration-versions.md) for how integration authors decide which kind of update they publish.

## Instance status[​](#instance-status "Direct link to Instance status")

An instance's `data.lifecycle` summarizes its status:

| Lifecycle                | Meaning                                                  |
| ------------------------ | -------------------------------------------------------- |
| `notDeployed`            | Created, but never deployed                              |
| `needsReconfiguration`   | The instance needs its configuration reviewed            |
| `needsUserConfiguration` | The current user needs to save their user-level settings |
| `paused`                 | Paused                                                   |
| `pendingChanges`         | Saved changes haven't been deployed                      |
| `active`                 | Running                                                  |

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

## Next steps[​](#next-steps "Direct link to Next steps")

* [Get started with headless configuration](https://prismatic.io/docs/get-started/build-integrations/headless-configuration.md) to run the example end to end
