Backflipfrom  Cloudmix

Backflip

Simple server-driven UI primitives

What is Backflip?

Backflip is a set of libraries that allow you to build server-driven UIs (heavily inspired by this blog post). Server-driven UIs allow your back-end to provide the configuration for how your UI should be rendered, even on native platforms.

Backflip allows you to support this pattern in your front-end code, while still allowing you to use your front-end framework of choice. It also provides fallback behaviour(s), as well as on-device caching, to ensure that your UI is always available to your users, even when they are offline.

Getting started

At a minimum, you need to install the @backflipjs/client and @backflipjs/server packages:

npm install @backflipjs/client @backflipjs/server

Depending on which front-end framework you are using, you may also want to install the dedicated @backflipjs/<framework> package as well (see Frameworks)

Server

In your server code, create a Server instance...

import { Server } from "@backflipjs/server";

const server = new Server();

//...

...then you can register your components.

//...

server.component("example", () => ({
  component: "Text",
  props: {
    content: "This is an example text component",
  },
}));

//...

The return type for a component() callback is as follows:

interface RenderedComponentConfig {
  component: string;
  props?: Record<string, unknown>;
  children?: RenderedComponentConfig[];
}

The Server instance exposes a WinterCG compatible fetch() method that accepts a Request and returns a Response.

//...
  
// For example, in a Cloudflare Worker...
export default {
  fetch: server.fetch.bind(server),
};

Client

In your client, you need to create a Registry instance...

import { Registry } from "@backflipjs/client";
  
const registry = new Registry();

//...

...you can then register each component you'd like available to be configured via the server.

//...

// `Text` is the component, for example a React component...
registry.registerComponent(Text, { name: "Text" });

//...

You also need to create a Client instance configured to point to your server.

import { Client } from "@backflipjs/client";
  
const client = new Client({
  url: "/api/components", // Could also be an absolute URL
});

//...

This Client can be used to send requests for a particular component configuration from your server.

//...
  
const config = await client.send("example");

//...

The return value for send() is the same as the one defined in the component() callback on the server, which can then be used to render the example component appropriately.

//...

function RenderComponent({
  component,
  props = {},
  children,
}: RenderComponentProps) {
  const Component = registry.get(component);

  if (!Component) {
    return null;
  }

  return (
    <Component {...props}>
      {children?.map((child, i) => (
        <RenderComponent key={`${component}_${child.component}_${i}`} {...child} />
      ))}
    </Component>
  );
}

//...

return <RenderComponent {...config} />;

//...

How it works

Server

The server-side of Backflip provides the configuration to your front-end for how your components should be rendered. You can do anything you are accustomed to running on your back-end (e.g. database calls) to decide which configuration to send down to your client.

You register components with your Server instance, which then exposes them via its REST API.

import { Server } from "@backflipjs/server";

interface ServerContext {
  locale: string;
}

const server = new Server<ServerContext>();

interface HomeInput {
  buttonVariant?: "primary" | "secondary";
}

server.component<HomeInput>("home", ({ ctx, input, resHeaders }) => {
  // Read a value from the `input` object
  const { buttonVariant } = input;
  // Run a convoluted "A/B" test
  const ab = Math.random() > 0.5 ? 1 : 0;

  // Setting a "Cache-Control" header instructs the Client how to cache the 
  // response (if a Cache has been configured)
  resHeaders.set("Cache-Control", "public, max-age=60");

  return {
    component: "Container",
    children: [
      {
        component: "Text",
        props: {
          content: ab
            ? "You are in the A group"
            : "You are in the B group",
        },
      },
      {
        component: "Button",
        props: {
          label: `${buttonVariant} button`,
          variant: buttonVariant,
        },
      },
      {
        component: "DateTime",
        props: {
          date: new Date(), // SuperJSON allows us to send some more complex data types
          prefix: "It is now",
        },
      },
      {
        component: "Text",
        props: {
          content: `From the server: your locale is ${ctx.locale}`, // Using `ctx` to send data to the client
        },
      },
    ],
  };
});

export { server };

The Server exposes a WinterCG compatible fetch() method that you can use to route requests to your Server.

import { Hono } from "hono";
import { server } from "./server";

const app = new Hono();

app.get("/api/components/:component", (c) => server.fetch(c.req.raw));

export default app;

The only requirements for how you route your Server are that it is exposed via a GET endpoint, and that the URL's last path parameter is the component name. Apart from that, you can customise your setup as you see fit.

Input

A Client can pass some input to the Server when requesting a component, which can then be used in the component() callback.

Context

The component() method provides a ctx object that you can use within your callback to un isolated logic per request.

The values in ctx can come from both the back-end and the front-end, allowing your client to provide extra information per request.

ctx is defined on the back-end as either an object or function that returns one:

import { Server } from "@backflipjs/server";

interface ServerContext {
  locale: string;
}

const server = new Server<ServerContext>({
  // `reqCtx` is the incoming request context object
  context: (req, reqCtx) => ({ locale: reqCtx.locale ?? "en-US" }),
});

//...

Serialization

The Server uses SuperJSON to serialise/deserialise data, so your input, ctx and props definitions can include (as well as valid JSON values):

  • undefined
  • BigInt
  • Date
  • RegExp
  • URL
  • Set
  • Map
  • Error

Registry

The Registry is (typically) a singleton you provide to your front-end logic which maps your components to the component names that your back-end describes when you request components.

import { Registry } from "@backflipjs/client";
import { Text } from "./components";
  
const registry = new Registry();

registry.registerComponent(Text, { name: "Text" });

//...

components can then be retrieved via their name.

//...

const TextComponent = registry.get("Text");

//...

If you call get() with a name that doesn't have a component registered to it, it will return null.

names are unique values, so you can only register one component per name.

Components

A component is any atomic UI presentation that can be described via the following configuration:

interface RenderedComponentConfig {
  component: string;
  props?: Record<string, unknown>;
  children?: RenderedComponentConfig[];
}

How the configuration of a component translates to how it is rendered is an implemetation detail that the core Backflip libraries are agnostic to (although we do have framework specific libraries that do handle the rendering of your components as well).

A component is registered with a unique name, which is how the Server refers to it.

Client

The Client makes requests to your back-end from your front-end code, to retreive your component configurations.

import { Cache, Client } from "@backflipjs/client";
        
const client = new Client({
  url: "/api/components", // The URL your `Server` is available at
  cache: new Cache(), // *Optional* `Cache` instance to support front-end caching
  context: () => ({ // *Optional* `ctx` to send to the server per request
    locale: Intl.DateTimeFormat().resolvedOptions().locale,
  }),
});
  

//...

The Client exposes a send() method that accepts a name and returns a Promise that resolves to the component configuration. If a request fails, an Error will be thrown.

If provided, the context will be serialized and sent within the query parameters of the request.

Cache

By default, every time your front-end renders your components, the Client will make a network request to get their configuration(s).

While ensuring your configurations include fresh data is desirable in many situations, there will also likely be components whose configuration rarely change - in these cases, you can utilize a Cache.

Any response from the Server that contains a Cache-Control header will be respected by the Client's Cache (if configured), e.g. a response with a Cache-Control header of public, max-age=60 will be cached for 60 seconds before the Client will make another request to the Server for that component configuration (with the same input).

import { Server } from "@backflipjs/server";

const server = new Server();

server.component("home", ({ resHeaders }) => {
  // ...
  resHeaders.set("Cache-Control", "public, max-age=60"); // Cache the response for 60 seconds
  // ...
});

//...
import { Cache, Client } from "@backflipjs/client";

const client = new Client({
  //...
  cache: new Cache(), // Simple default in-memory cache
  //...
});

//...

The default Cache provided is an in-memory one - if you would like persistance between sessions then you need to utilize a Cache with a storage layer. The @backflipjs/browser package provides two Cache implementations that support persisted storage:

  • LocalStorageCache (using the localStorage API)
  • IDBCache (using IndexedDB)
import { Client } from "@backflipjs/client";
import { IDBCache } from "@backflipjs/browser";

const client = new Client({
  //...
  cache: new IDBCache(),
  //...
});

//...

For React Native caching see below.


Frameworks

React

Install the @backflipjs/react package along with the other dependencies

npm install @backflipjs/client @backflipjs/server @backflipjs/react

In your front-end code, you need to create a Registry and Client and pass them into the Provider component.

import { Client, Registry, Provider } from "@backflipjs/react";
    
const registry = new Registry();
const client = new Client({ /* ... */ });

// ...

function App() {
  return (
    <Provider client={client} registry={registry} devMode>
      {/* ... */}
    </Provider>
  )
}

//...

The devMode prop optionally turns development mode on when true, which will log out warnings when for various misconfigurations that can occurr as you develop your application with Backflip.

To render your components, you can use the RenderComponent component:

import { RenderComponent } from "@backflipjs/react";

// ...

return <RenderComponent name="example" />;

//...

As well as the mandatory name prop, you can also pass the following optional props:

  • default (a default configuration to use if the request to the Server fails)
  • error (a React component to render if the component fails to render due to an error - typically from the Server)
  • fallback (a React component to render if the component fails to render for any reason)
  • input (an input value to be passed to the Server when processing the request)
  • loading (a React component to render while the component is loading a request from the Server)
import { RenderComponent } from "@backflipjs/react";

// ...

return (
  <RenderComponent
    name="example"
    default={{
      component: "Text",
      props: {
        content: "This is a default text component",
      },
    }}
    error={<p>There was an error rendering this component</p>}
    fallback={<p>This component is not available</p>}
    input={{ buttonVariant: "primary" }}
    loading={<p>Loading...</p>}
  />
);

//...

An example React Remix application can be found in the Github repository.

React Native

React Native can also utilize the @backflipjs/react dependency. For the most part, you can follow the above React instructions to set it up.

There is a @backflipjs/native package that provides a React Native Expo compatible SecureStoreCache that uses expo-secure-store as a cache storager layer.

import { SecureStoreCache } from "@backflipjs/native";
import { Client } from "@backflipjs/react";

const client = new Client({
  //...
  cache: new SecureStoreCache(),
  //...
});

//...

An example Expo application can be found in the Github repository.

Vue

Coming soon...

Svelte

Coming soon...

SolidJS

Coming soon...