@compose-run/client
v0.0.36
Published
a whole backend without leaving your react app
Downloads
77
Readme
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 requiredCloud 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 keep you where you want to be: in your React components. 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, so you can focus solely on building your UI for your users. 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:
Cloud-versions of React's built-in hooks:
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>
);
}
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: "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)
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: "[email protected]",
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 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 />;
}
}
Permissions
Let's prevent unauthenticated users from incrementing the counter:
import { useCloudReducer } from "@compose-run/client";
function Counter() {
const [count, dispatchCountAction] = useCloudReducer({
name: "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>
);
}
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>
);
}
useCloudReducer
Counter
import { useCloudReducer } from "@compose-run/client";
function Counter() {
const [count, dispatchCountAction] = useCloudReducer({
name: "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>
);
}
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 />;
}
}
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 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
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
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 dispatchedaction
- the action that was dispatcheduserId
- 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.
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 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
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
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)
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 libraryshared-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/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.