Skip to content

compose-run/client

main
Switch branches/tags
Code

Latest commit

 

Git stats

Files

Permalink
Failed to load latest commit information.
Type
Name
Latest commit message
Commit time

ComposeJS – a whole backend inside React

Compose is a modern backend-as-a-service for React apps.

  • Setup in seconds: npm install @compose-run/client or CodeSandbox, no account required

  • Cloud versions of the react hooks you already know & love:

  • Authentication & realtime web sockets built-in

  • 100% serverless

  • Cloud functions, deployed on every save!

  • TypeScript bindings

We're friendly! Come say hi or ask a question in our Community chat 👋

Warning: This is beta software. There will be bugs, downtime, and unstable APIs. (We hope to make up for the early, rough edges with white-glove service from our team.)

Table of Contents

Guide

This guide describes the various concepts used in Compose, and how they relate to each other. To get a complete picture of the system, it is recommended to go through it in the order it is presented in.

Introduction

Compose provides a set of tools for building modern React apps backed by a cloud database.

The design goal of Compose is to get out of your way, so you can focus on crafting the perfect user interface for your users. The whole system is built around React hooks and JavaScript calls. There's no CLI, admin panel, query language, or permissions language. It's just React and JavaScript. Using Compose should feel like you're building a local app – the cloud database comes for free.

Compose is simple. There are just two parts:

  1. A key-value store, accessed via cloud-versions of React's built-in hooks:

  2. Users & authentication

A simple example

The simplest way to get started is useCloudState. We can use it to make a cloud counter button:

import { useCloudState } from "@compose-run/client";

function Counter() {
  const [count, setCount] = useCloudState({
    name: "examples/count",
    initialState: 0,
  });

  return (
    <div>
      <h1>Hello Compose</h1>
      <button onClick={() => setCount(count + 1)}>
        I've been clicked {count} times
      </button>
    </div>
  );
}

Edit Compose useCloudState Counter

compose-counter

Equivalent useState example

If you've used useState before, this code should look familiar:

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

While Compose follows most React conventions, it does prefer named arguments to positional ones – except in cases of single-argument functions.

State

A state in Compose is a cloud variable. It can be any JSON-serializable value. States are created and managed via the useCloudState and useCloudReducer hooks, which are cloud-persistent versions of React's built-in useState and useReducer hooks.

  • useCloudState is simple. It returns the current value of the state, and a function to set it.

  • useCloudReducer is more complex. You can't update the state directly. You have to dispatch an action to tell the reducer to update it. More on this below.

Names

Each piece of state needs a name. Compose has a global namespace. One common way to avoid collisions is to prefix the name with your app's name, i.e. "myApp/myState".

An example with useCloudReducer

The simplicity of useCloudState are also its downsides: anyone can set it whenever to anything.

Enter useCloudReducer. It allows you to protect your state from illegal updates. Instead of setting the state directly, you dispatch an action to tell the reducer to update it.

We can update the simple counter example from above to useCloudReducer:

import { useCloudReducer } from "@compose-run/client";

function Counter() {
  const [count, dispatchCountAction] = useCloudReducer({
    name: "examples/reducer-count",
    initialState: 0,
    reducer: ({ previousState, action }) => {
      switch (action) {
        case "increment":
          return previousState + 1;
        default:
          throw new Error(`Unexpected action: ${action}`);
      }
    },
  });
  return (
    <button onClick={() => dispatchCountAction("increment")}>{count}</button>
  );
}

Edit Compose Counter (useCloudReducer)

The upsides of using useCloudReducer here are that we know:

  1. the state will always be a number
  2. the state will only every increase, one at a time
  3. that we will never "miss" an update (each update is run on the server, in order)

Reducers run in the cloud

Your reducer function runs in the cloud (on our servers) every time it receives an action.

We get your reducer code on the server by calling .toString() on your function and sending it to the server. This is how we're able to deploy your function on every save. Every time you change the function, we update it on the server instantly.

If someone else tries to change the function, we'll throw an error. Whoever is logged in when a reducer's name is first used is the "owner" of that reducer, and the only one who can change it.

Currently the reducer function is extremely limited in what it can do. It cannot depend on any definitions from outside itself, require any external dependencies, or make network requests. These capabilities will be coming shortly. For now, reducers are used to validate, sanitize, and authorize state updates.

Any console.log calls or errors thrown inside your reducer will be streamed to your browser if you're are online. If not, those debug messages will be emailed to you.

Create a Developer Account (optional)

If you've been following along, you know that you don't have to create an account to get started with Compose.

However, it only takes 10 seconds (literally), and it will give you access to the best Compose features for free!

import { magicLinkLogin } from "@compose-run/client";

magicLinkLogin({
  email: "your-email@gmail.com",
  appName: "Your New Compose App",
});

Edit Compose Developer Account

Then click the magic link in your email.

Done! Your account is created, and you're logged into Compose in whatever tab you called that function.

Logging in a user

Logging in users is just as easy as creating a developer account. In fact, it's the same function.

Let's add a simple Login UI to our counter app:

import { magicLinkLogin } from "@compose-run/client";

function Login() {
  const [email, setEmail] = useState("");
  const [loginEmailSent, setLoginEmailSent] = useState(false);

  if (loginEmailSent) {
    return <div>Check your email for a magic link to log in!</div>;
  } else {
    return (
      <div style={{ display: "flex" }}>
        <h1>Login</h1>
        <input onChange={(e) => setEmail(e.target.value)} />
        <button
          onClick={async () => {
            await magicLinkLogin({ email, appName: "My App" });
            setLoginEmailSent(true);
          }}
        >
          Login
        </button>
      </div>
    );
  }
}

function App() {
  const user = useUser();
  if (user) {
    return <Counter />;
  } else {
    return <Login />;
  }
}

Edit Compose Login Users & Authenticated Count

Permissions

Let's prevent unauthenticated users from incrementing the counter:

import { useCloudReducer } from "@compose-run/client";

function Counter() {
  const [count, dispatchCountAction] = useCloudReducer({
    name: "examples/authenticated-count",
    initialState: 0,
    reducer: ({ previousState, action, userId }) => {
      if (!userId) {
        throw new Error("Unauthenticated");
      }

      switch (action) {
        case "increment":
          return previousState + 1;
        default:
          throw new Error(`Unexpected action: ${action}`);
      }
    },
  });
  return (
    <button onClick={() => dispatchCountAction("increment")}>{count}</button>
  );
}

Edit Compose Login Users & Authenticated Count

Breaking down walls

Compose strives to break down unnecessary boundaries, and rethink the backend interface from first principles. Some concepts from other backend frameworks are not present in Compose.

Compose has no concept of an "app". It only knows about state and users. Apps are collections of state, presented via a UI. The state underpinning a Compose app is free to be used seamlessly inside other apps. This breaks down the walls between app data silos, so we can build more cohesive user experiences. Just like Stripe remembers your credit card across merchants, Compose remembers your states across apps.

A user's Compose userId is the same no matter who which Compose app they login to – as long as they use the same email address. This enables you to embed first-class, fullstack components from other Compose apps into your app, and have the same user permissions flow through.

Finally, you'll notice that there is no distinction between a developer account and a user account in Compose. We want all users to have a hand in shaping their digital worlds. This starts by treating everyone as a developer from day one.

Branches

When developing an app with users, you'll likely want to create isolated branches for each piece of your app's state. A simple way to accomplish this is to add the git branch's name to the state's name:

useCloudReducer({
  name: `${appName}/${process.env.BRANCH_NAME}/myState`,
  ...

(You'd need to add BRANCH_NAME=$(git symbolic-ref --short HEAD) before your npm start command runs.)

Migrations

Compose doesn't have a proper migration system yet, but we are able to achieve atomic migrations in a couple steps.

The basic idea is that each new commit is an isolated state. This is achieved by adding the git commit hash into the state's name.

useCloudReducer({
  name: `${appName}/${process.env.BRANCH_NAME}/${process.env.COMMIT_HASH}/myState`,
  ...

(You'd need to add COMMIT_HASH=$(git log --pretty=format:'%H' -n 1) before your npm start command runs.)

The trick is that the initialState would be the last state from the prior commit hash + your migration function.

useCloudReducer({
  initialState: getPreviousState(stateName).then(migration),
  ...

You can read more about this scheme in the Compose Community README, where it is currently implemented.

Frameworks

We are agonistic about your choice of React framework. Compose works with:

  • Create React App
  • NextJS
  • Gatsby
  • Parcel
  • Vanilla React
  • TypeScript & JavaScript
  • yarn, npm, webpack, etc
  • etc

There can be issues with using certain Babel features inside your reducer functions, but we're working on a fix!

Deployment

Compose is deployed at runtime. If your code works, it's deployed.

For deploying your frontend assets, we recommend the usual cast of characters for deploying Jamstack apps:

  • Vercel
  • Netlify
  • Heroku
  • Github Pages
  • etc

Examples

useCloudState Counter

import { useCloudState } from "@compose-run/client";

function Counter() {
  const [count, setCount] = useCloudState({
    name: "examples/count",
    initialState: 0,
  });

  return (
    <div>
      <h1>Hello Compose</h1>
      <button onClick={() => setCount(count + 1)}>
        I've been clicked {count} times
      </button>
    </div>
  );
}

Edit Compose useCloudState Counter

useCloudReducer Counter

import { useCloudReducer } from "@compose-run/client";

function Counter() {
  const [count, dispatchCountAction] = useCloudReducer({
    name: "examples/reducer-count",
    initialState: 0,
    reducer: ({ previousState, action }) => {
      switch (action) {
        case "increment":
          return previousState + 1;
        default:
          throw new Error(`Unexpected action: ${action}`);
      }
    },
  });
  return (
    <button onClick={() => dispatchCountAction("increment")}>{count}</button>
  );
}

Edit Compose Counter (useCloudReducer)

Login

import { magicLinkLogin } from "@compose-run/client";

function Login() {
  const [email, setEmail] = useState("");
  const [loginEmailSent, setLoginEmailSent] = useState(false);

  if (loginEmailSent) {
    return <div>Check your email for a magic link to log in!</div>;
  } else {
    return (
      <div style={{ display: "flex" }}>
        <h1>Login</h1>
        <input onChange={(e) => setEmail(e.target.value)} />
        <button
          onClick={async () => {
            await magicLinkLogin({ email, appName: "My App" });
            setLoginEmailSent(true);
          }}
        >
          Login
        </button>
      </div>
    );
  }
}

function App() {
  const user = useUser();
  if (user) {
    return <div>Hello, {user.email}!</div>;
  } else {
    return <Login />;
  }
}

Edit Compose Login Users & Authenticated Count

Compose Community Chat App

The Compose Community chat app is built on Compose. Check out the code and join the conversation!

API

useCloudState

useCloudState is React hook that syncs state across all instances of the same name parameter.

useCloudState<State>({
  name,
  initialState,
}: {
  name: string,
  initialState: State,
}) : [State | null, (State) => void]

useCloudState requires two named arguments:

  • name (required) is a globally unique identifier string
  • initialState (required) is the initial value for the state; can be any JSON object

It returns an array of two values, used to get and set the value of state, respectively:

  1. The current value of the state. It is null while the state is loading.
  2. A function to set the state across all references to the name parameter.

useCloudReducer

useCloudReducer is React hook for persisting complex state. It allows you to supply a reducer function that runs on Compose's servers to handle state update logic. For example, your reducer can disallow invalid or unauthenticated updates.

function useCloudReducer<State, Action, Response>({
  name,
  initialState,
  reducer,
}: {
  name: string;
  initialState: State | Promise<State>;
  reducer: ({
    previousState,
    action,
    resolve,
    userId,
  }: {
    previousState: State;
    action: Action;
    resolve: (response: Response) => void;
    userId: number | null;
  }) => State;
}): [State | null, (action?: Action) => Promise<Response>];

useCloudReducer requires three named arguments:

  • name (required) is a globally unique identifier string
  • initialState (required) is the initial value for the state; can be any JSON object
  • reducer (required) is a function that takes the current state, an action and context, and returns the new state

It returns an array of two values, used to get the value of state and dispatch actions to the reducer, respectively:

  1. The current value of the state. It is null while the state is loading.
  2. A function to dispatch actions to the cloud reducer. It returns a Promise that resolves when the reducer calls resolve on that action. (If the reducer doesn't call resolve, the Promise never resolves.)

The Reducer Function

The reducer function runs on the Compose servers, and is updated every time it is changed – as long as its changed by its original creator. It cannot depend on any definitions from outside itself, require any external dependencies, or make network requests.

The reducer itself function accepts three four named arguments:

  • previousState - the state before the action was dispatched

  • action - the action that was dispatched

  • userId - the dispatcher user's Compose userId (or null if none)

  • resolve - a function that you can call to resolve the Promise returned by the dispatch function

It returns the new state.

Debugging

The reducer function is owned by whoever created it. Any console.log calls or errors thrown inside the reducer will be streamed to that user's browser console if they are online. If not, those debug messages will be emailed to them.

Compose discards any actions that do not return a new state or throw an error, and leave the state unchanged.

magicLinkLogin

Login users via magic link.

function magicLinkLogin({
  email,
  appName,
  redirectURL,
}: {
  email: string;
  appName: string;
  redirectURL?: string;
}): Promise<null>;

It accepts two required named arguments and one optional named argument:

  • email - (required) the email address of the user
  • appName - (required) the name of the app in the magic email link that is sent to the user
  • redirectURL - (optional) the URL to redirect to after the user logs in. It defaults to the current window.location.href if not provided.

It returns a Promise that resolves when the magic link email is successfully sent.

useUser

useUser is a React hook to get the current user ({email, id}) or null if no user is logged in.

useUser(): {email : string, id: number} | null

logout

This logs out the currently logged in user if there is one. It is called with no arguments: logout().

globalify

globalify is useful utility for adding all of Compose's function to your global window namespace for easy access in the JS console.

getCloudState

getCloudState(name: string) returns a Promise that resolves to the current value of the named state.

It works for states created via either useCloudState and useCloudReducer.

setCloudState

setCloudState({name : string, state: State}) is a utility function for setting state.

It can be used outside of a React component. It is also useful for when you want to set state without getting it.

It will fail to set any states with attached reducers, because those can only be updated by dispatching an action to the reducer.

dispatchCloudAction

dispatchCloudAction<Action>({name: string, action: Action}) is a utility function for dispatching actions to reducers.

It can be used outside of a React component. It is also useful for when you want to dispatch actions without getting the state.

FAQ

What kind of state can I store?

You can store any JSON object.

How much data can I store?

Each name shouldn't hold more than ~25,000 objects or ~4MB because all state needs to fit into your users' browsers.

This limitation will be lifted when we launch useCloudQuery (coming soon).

How do I query the state?

Each named state in Compose is analogous to a database table. However instead of using SQL or another query language, you simply use client-side JavaScript to slice and dice the state to get the data you need.

Of course this doesn't scale past what state fits inside the user's browser. However we find that this limitation is workable for prototyping an MVP of up to hundreds of active users.

We plan to launch useCloudQuery soon, which will enable you to run server-side JavaScript on your state before sending it to the client, largely removing this size limitation, while still keeping the JavaScript as the "query language".

How do I debug the current value of the state?

You can get the current value of the state as a Promise and log it:

const testState = await getCloudState({ name: "test-state" });

console.log(testState);

You may need to use globalify to get access to Compose functions (like getCloudState) in your JS console.

You can also print out all changes to cloud state from within a React component:

const [testState, setTestState] = useCloudState({
  name: "test-state",
  initialState: [],
});

useEffect(() => console.log(testState), [testState]);

Does it work offline?

Compose doesn't allow any offline editing. We plan to add a CRDT mode in the future which would enable offline edits.

Pricing

Compose is currently free while we work on a pricing model.

Contributing

How to use

  • Install dependencies npm install
  • Build npm run build

File Structure

There are just two files:

  • index.ts, which contains the whole library
  • shared-types.ts, which contains all the types that are shared between the client and server

Developing locally

You can use npm link if you want to test out changes to this client library in another project locally. For example, let's say we wanted to test out a change to this client library in the @compose-run/communityrepo:

  1. In this repo, run npm link
  2. In this repo, run npm link ../community/node_modules/react [^1]
  3. In @compose-run/community, run npm link @compose-run/client
  4. In this repo, run npm run build

npm link can be tricky to get working, particularly because you have to link two repos in this case! npm ls and npm ls -g can be handy for debugging. Additionally, deleting your node_modules directory and npm installing from scratch can be helpful.

[1]: This step is to stop React from complain that you're "breaking the rules of hooks" by having "more than one copy of React in the same app", as described in the React docs.