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.)
- ComposeJS – a whole backend inside React
- Table of Contents
- Guide
- Examples
- API
- FAQ
- Pricing
- Contributing
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.
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:
-
A key-value store, accessed via cloud-versions of React's built-in hooks:
-
Users & authentication
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>
);
}
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.
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.
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"
.
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>
);
}
The upsides of using useCloudReducer
here are that we know:
- the state will always be a number
- the state will only every increase, one at a time
- that we will never "miss" an update (each update is run on the server, in order)
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.
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",
});
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 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 />;
}
}
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>
);
}
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.
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.)
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.
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!
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
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>
);
}
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>
);
}
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 />;
}
}
The Compose Community chat app is built on Compose. Check out the code and join the conversation!
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 stringinitialState
(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:
- The current value of the state. It is
null
while the state is loading. - A function to set the state across all references to the
name
parameter.
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 stringinitialState
(required) is the initial value for the state; can be any JSON objectreducer
(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:
- The current value of the state. It is
null
while the state is loading. - A function to dispatch actions to the cloud reducer. It returns a
Promise
that resolves when the reducer callsresolve
on that action. (If the reducer doesn't callresolve
, thePromise
never resolves.)
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 ComposeuserId
(ornull
if none) -
resolve
- a function that you can call to resolve the Promise returned by thedispatch
function
It returns the new state.
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.
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 userappName
- (required) the name of the app in the magic email link that is sent to the userredirectURL
- (optional) the URL to redirect to after the user logs in. It defaults to the currentwindow.location.href
if not provided.
It returns a Promise
that resolves when the magic link email is successfully sent.
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
This logs out the currently logged in user if there is one. It is called with no arguments: logout()
.
globalify
is useful utility for adding all of Compose's function to your global window
namespace for easy access in the JS console.
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({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<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.
You can store any JSON object.
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).
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".
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]);
Compose doesn't allow any offline editing. We plan to add a CRDT mode in the future which would enable offline edits.
Compose is currently free while we work on a pricing model.
- Install dependencies
npm install
- Build
npm run build
There are just two files:
index.ts
, which contains the whole libraryshared-types.ts
, which contains all the types that are shared between the client and server
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/community
repo:
- In this repo, run
npm link
- In this repo, run
npm link ../community/node_modules/react
[^1] - In
@compose-run/community
, runnpm link @compose-run/client
- 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 install
ing 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.