What if useState was your backend?
Developmentā¢ā¢14 minutes read
Stepan Parunashvili
Guest Author
From š to š in millisecondsābuild a full-stack emoji chat app with Expo + Instant. Real-time, offline, and no backend blues.

This is a guest post by Stepan Parunashvili of Instant. Instant makes you more productive by giving your frontend a real-time database. By the end of the post youāll build a full-stack, multiplayer chat app!
...
Expo makes frontends easier to build. Write once and you get app on iOS, Android and web. Make a change and your app hot reloads, no need to wait for binaries to build. Fix a bug and you can send patches directly to your users, youāre not stuck in app store submissions. And when itās time to push to Testflight, donāt worry about provisioning profiles or XCode environments, run npx testflight and youāre ready to go.
A development experience like that can change both how you work and what you can build. When every save means you wait a few seconds, programming feels like drudgery. Get that down to milliseconds and programming feels like flow. When introducing a bug means hours of downtime, youāre in for a stressful release process. Get that down to live patches and you ship quickly again.
Write with flow, ship quickly, get faster feedback, and now youāre building a different app in a different way. Thatās the story of frontend. But what happens when you get to the backend?
The state of backends
How do you save your userās data? Suddenly your work is full of schleps again. Letās remember what we need to do.
Most of the time you start with a server. You spin up a database and a backend to host your endpoints (But donāt forget Redis and a good ORM). And when you set up your endpoints, return as little data as possible or youāll send unnecessarily large payloads to your users. Remember folks may still use old clients, so every backend update has to be backwards-compatible:
And weāre only getting started. Now you need to connect your backend to your app. First you coordinate calls to different endpoints. (Since we returned as little data as possible, we often have to make multiple fetches to paint a single screen). Then you normalize those responses and stick them into stores. Then you write selectors to get just what each screens needs. Then to handle mutations. For every save, you write your change to the store, then send it to the server, and then handle rollback:
Do this and you have a fully-fledged app. But you canāt stop there. When you build mobile apps offline mode is a real concern. Time to dust off expo-sqlite and cache your data. Make sure to keep your mutations saved too, so you can send them to the server when youāre back online. Then what if you want multiplayer? Time to add stateful servers and to worry about broadcasting changes:
Oi. When lines in a diagram cross like this, you know youāre in for trouble. And to make things worse, every new feature requires the same kind of song and dance: add models, write endpoints, stores, selectors, and finally the UI.
A useState-ish backend
Thatās a lot of steps to go through to save your userās data. Does it have to be this way? In 2021, we realized that most of these schleps were actually database problems in disguise.
Maybe a database-looking solution could solve them. For example, if you had a database inside your Expo app, you wouldn't need to think about stores, selectors, endpoints, or local caches: youād just write queries. If these queries were multiplayer by default, you wouldn't have to worry about stateful servers. And if your database supported rollback, you'd get optimistic updates for free.
Well, we tried to build a database-looking solution, and 2 years later released Instant. Instant makes you productive by giving your frontend a real-time database.
You write queries that stay in sync and work offline. Every save updates immediately, and it comes with built-in auth and permissions. How does it look? Hereās a snippet that connects to your backend, syncs data and lets you change it too:
// ą¼¼ 㤠ā_ā ą¼½ć¤ Real-time Chat// ----------------------------------// * Updates instantly// * Multiplayer// * Works offlineimport { init, tx, id } from "@instantdb/react-native";const db = init({appId: process.env.EXPO_PUBLIC_INSTANT_APP_ID,});function Chat() {// 1. Readconst { isLoading, error, data } = db.useQuery({messages: {},});// 2. Writeconst addMessage = (message) => {db.transact(tx.messages[id()].update(message));};// 3. Render!return <UI data={data} onAdd={addMessage} />;}
The only missing piece is in this code snippet is the UI component, everything else comes as you see it.
If you squint, useQuery and db.transact start to resemble a useState hook. For example, if you just wanted to save messages with useState, youād write:
function Chat() {const [messages, setMessages] = useState([]);// ...}
Well, replace messages with db.useQuery, and setMessages with db.transact, and you have our code snippet:
function Chat() {// 1. Readconst { isLoading, error, data } = db.useQuery({messages: {},});// 2. Writeconst addMessage = (message) => {db.transact(tx.messages[id()].update(message));};// ...}
A few extra lines but now your data is persisted, works offline, and is multiplayer! Pretty cool.
Operationā¦EmojiChat
Is it really so simple though? Well, letās put Expo and Instant to a bit of a test right inside this essay, and build an app. I suggestā¦EmojiChat!
Hereās the idea. WhatsApp is great, but why the need for a text box? Sometimes, all you need to communicate is a few emojis. Thatās where Emoji Chat comes in.
You can use EmojiChat to express your deepest thoughts with a few well-curated emojis: āš„ā, āā¤ļøā, āš»ā, āšā.
Instead of writing āI miss you, want to meet up?ā, you could simply send āš»ā¤ļøš„šā. Now thatās expression!
A plan of attack
Okay, letās build EmojiChat. Hereās what weāll do: weāll start with a basic Expo template and hack our app out from there. Weāll take it step by step:
- Weāll scaffold our UI.
- Weāll create our Instant backend.
- Weāll add our
messagestable. - Then, weāll connect our UI to our backend
Once thatās done, weāll have real-time chat! Users will be able to send emojis and it will show up everywhere. We wonāt stop there though. We canāt forget about permissions:
- In the end weāll add permissions to lock down who can change what.
Once we do that we can celebrate a bit and talk about what else we can build. Okay, letās get hacking.
Goal 1: Letās build our UI
First things first, time for some fun with Expo : )
create-expo
We can use our trusty npx create-expo-app and pick a starter. I really like the with-tailwindcss template:
-Ā npx create-expo-app -e with-tailwindcss emoji-chat-Ā cd emoji-chat# š„ and we're off to the races-Ā npm run startNow we have a repo with expo-router and Nativewind. We can replace src/app/index.tsx with something weāll start to evolve:
import React from "react";import { Text } from "react-native";import { SafeAreaView } from "react-native-safe-area-context";export default function Page() {return (<SafeAreaView className="flex flex-1 items-center justify-center"><Text className="text-5xl text-blue-500">Hello, world!</Text></SafeAreaView>);}
Take a look at your Expo Go, and youāll see a hello world!
Okay, next step is the core of our UI: our button.
A slick button
Normally tutorials only show you a few minimal components. But for EmojiChat we need to go deeper ā at least for our Emoji Button. Our button is the primary medium for our users to express themselves, so it should feel slick. Hereās my suggestion:
When we press our button, itās going to feel like it sinks inside the screen.
EmojiPill
To start, weāll create a little EmojiPill component. This is our rounded pill that we can place emojis in. Hereās src/components/emoji.tsx:
import { Text, View } from "react-native";export const EMOJIS = ["š„", "ā¤ļø", "š»", "š"] as const;// This infers `type Emoji = "š„" | "ā¤ļø" | "š»" | "š"`export type Emoji = (typeof EMOJIS)[number];const EMOJI_SIZE = 54;export function EmojiPill({ label }: { label: Emoji }) {return (<ViewclassName="bg-gray-200 rounded-xl flex items-center justify-center"style={{width: EMOJI_SIZE,height: EMOJI_SIZE,}}><Text className="text-2xl">{label}</Text></View>);}
Now to use it in our index.tsx file:
import React from "react";import { EmojiPill } from "@/components/emoji";import { SafeAreaView } from "react-native-safe-area-context";export default function Page() {return (<SafeAreaView className="flex flex-1 items-center justify-center"><EmojiPill label="š»" /></SafeAreaView>);}
And weāve got a cool pill!
Time to turn this pill into a button.
ScaleButton
React Nativeās animated library is going to come in real handy for us. Letās head on over to src/components/ScaleButton.tsx and sketch it out:
import React, { useRef } from "react";import { Animated, Pressable, PressableProps } from "react-native";const AnimatedPressable = Animated.createAnimatedComponent(Pressable);export function ScaleButton(props: PressableProps) {const scale = useRef(new Animated.Value(1)).current;return (<AnimatedPressableonPressIn={() => {Animated.spring(scale, {toValue: 0.9,useNativeDriver: true,}).start();}}onPressOut={() => {Animated.spring(scale, {toValue: 1,useNativeDriver: true,}).start();}}style={{transform: [{ scale }],}}{...props}/>);}
What have we done here? Weāve created a scale animated value. By default, the value is 1 , which means our button is going to be the same size. As we press in, we change the scale to 0.9, causing our button to get smaller and feel like it āsinkedā! As we press out, the button goes back to 1 (normal) again.
Letās try it out:
import React from "react";import { EmojiPill } from "@/components/emoji";import { SafeAreaView } from "react-native-safe-area-context";import { ScaleButton } from "@/components/ScaleButton";export default function Page() {return (<SafeAreaView className="flex flex-1 items-center justify-center"><ScaleButton><EmojiPill label="š»" /></ScaleButton></SafeAreaView>);}
And now we have our sinking button!
The layout
Now that we have our button, itās time to build out a screen! Hereās what we want to do:
Weāll have a little bar at the bottom with our emoji buttons. Then weāll have a scrolled container of all our messages. The tricky part in our UI is in the alignment: our emojis should line up both horizontally and vertically. For example, the āghostā emoji should stay in the āghostā row, the āheartā emoji in in the āheartā row, and so on.
The simplest way to get this done is use fixed widths.
emojiOffset
If we know the size of our emoji pills and the gaps between them, we can figure out where to place a particular emoji. Hereās emojiOffset to do just that:
// ...export const EMOJIS = ["š„", "ā¤ļø", "š»", "š"] as const;export type Emoji = (typeof EMOJIS)[number];export const EMOJI_SIZE = 54;export const EMOJI_GAP = 16;export function emojiOffset(emoji: Emoji) {const idx = EMOJIS.indexOf(emoji);return idx * EMOJI_SIZE + idx * EMOJI_GAP;}// ...
To put it into action, this is how emojiOffset would figure out the placement for āšā:
Pretttty cool!
EmojiBar
Our phones can get large and FlexBox could stretch out the gaps for our emoji buttons. To make sure we place our emojis correctly, we can write up an EmojiBar, a fixed-width wrapper around our emojis:
// ...export const BAR_WIDTH =EMOJIS.length * EMOJI_SIZE + (EMOJIS.length - 1) * EMOJI_GAP;export function EmojiBar({ children }: React.PropsWithChildren) {return (<View className="flex-row justify-center"><ViewclassName="flex-row"style={{width: BAR_WIDTH,}}>{children}</View></View>);}// ...
Put it all together, and our emoji.tsx file looks like this:
import React from "react";import { View, Text } from "react-native";export const EMOJIS = ["š„", "ā¤ļø", "š»", "š"] as const;export type Emoji = (typeof EMOJIS)[number];export const EMOJI_SIZE = 54;export const EMOJI_GAP = 16;export function emojiOffset(emoji: Emoji) {const idx = EMOJIS.indexOf(emoji);return idx * EMOJI_SIZE + idx * EMOJI_GAP;}export const BAR_WIDTH =EMOJIS.length * EMOJI_SIZE + (EMOJIS.length - 1) * EMOJI_GAP;export function EmojiBar({ children }: React.PropsWithChildren) {return (<View className="flex-row justify-center"><ViewclassName="flex-row"style={{width: BAR_WIDTH,}}>{children}</View></View>);}export function EmojiPill({ label }: { label: Emoji }) {return (<ViewclassName="bg-gray-200 rounded-xl flex items-center justify-center"style={{width: EMOJI_SIZE,height: EMOJI_SIZE,}}><Text className="text-2xl">{label}</Text></View>);}
Building the screen
Okay, we have all the components we need for our screen. Letās use them. Here it goes:
import {EMOJI_GAP,EmojiBar,EmojiPill,EMOJIS,Emoji,emojiOffset,} from "@/components/emoji";import React, { useState } from "react";import { SafeAreaView, ScrollView, View, Text } from "react-native";import { ScaleButton } from "../components/ScaleButton";type Message = {id: string;body: Emoji;createdAt: number;};export default function Page() {const [messages, setMessages] = useState<Message[]>([]);return (<SafeAreaView className="flex-1 m-4"><ScrollView className="py-4">{messages.toReversed().map((msg) => {const marginLeft = emojiOffset(msg.body);return (<View className="mb-4" key={msg.id}><EmojiBar><View style={{ marginLeft }}><EmojiPill label={msg.body} /></View></EmojiBar></View>);})}</ScrollView><EmojiBar>{EMOJIS.map((emoji, idx) => (<ScaleButtonkey={emoji}onPress={() => {setMessages((prev) => {return [...prev,{id: prev.length.toString(),body: emoji,createdAt: Date.now(),},];});}}><View style={{ marginLeft: idx == 0 ? 0 : EMOJI_GAP }}><EmojiPill label={emoji} /></View></ScaleButton>))}</EmojiBar></SafeAreaView>);}
Load our page, and we have our UI!
Nice! Letās look at what we just did.
We added a Message type:
// ...type Message = {id: string;body: Emoji;createdAt: number;};// ...
Then we created a useState hook:
// ...export default function Page() {const [messages, setMessages] = useState<Message[]>([]);// ...}// ...
We render our messages into a ScrollView, and use emojiOffset to position each emoji:
// ...export default function Page() {// ...return (// ...<ScrollView className="py-4">{messages.toReversed().map((msg) => {const marginLeft = emojiOffset(msg.body);return (<View className="mb-4" key={msg.id}><EmojiBar><View style={{ marginLeft }}><EmojiPill label={msg.body} /></View></EmojiBar></View>);})}</ScrollView>// ...)}
Then we built out our buttons. Every time we press an emoji, we setMessages and add a new message:
// ...export default function Page() {// ...return (// ...<EmojiBar>{EMOJIS.map((emoji, idx) => (<ScaleButtonkey={emoji}onPress={() => {setMessages((prev) => {return [...prev,{id: prev.length.toString(),body: emoji,createdAt: Date.now(),},];});}}><View style={{ marginLeft: idx == 0 ? 0 : EMOJI_GAP }}><EmojiPill label={emoji} /></View></ScaleButton>))}</EmojiBar>// ...)}
With that, we have our screen!
Goal 2: Create a backend
Okay, now to create our backend. At Instant we call each backend an āappā. We can create an app right inside our terminal with npx instant-cli.
login
To start weāll log into Instant:
-Ā npx instant-cli login-Ā Let's log you in!? This will open instantdb.com in your browser, OK to proceed? yes
Waiting for authentication...Successfully logged in as stopa@instantdb.com!init
Now that youāre logged in, you can create an app:
-Ā npx instant-cli initChecking for an Instant SDK...Couldn't find an Instant SDK in your package.json, let's install one!? Which package would you like to use? @instantdb/react-nativeā Installed @instantdb/react-native using npm.? What would you like to do? Create a new app? What would you like to call it? emoji-chatLooks like you don't have a `.env` file yet.If we set `EXPO_PUBLIC_INSTANT_APP_ID`, we can remember the app that you chose for all future commands.? Want us to create this env file for you? yesCreated `.env` file!Pulling schema...ā
Wrote schema to instant.schema.tsPulling perms...ā
Wrote permissions to instant.perms.tsWoohoo. This just:
- Installed
@instantdb/react-native, - Created a schema and a perms file.
- Added
EXPO_PUBLIC_INSTANT_APP_IDto your env with your band new app!
Prettyy cool. If you look at your Instant Dashboard, youāll see your app there š
Goal 3: Add the messages table
Letās take a peak at the instant.schema.ts file that instant-cli created:
import { i } from "@instantdb/react-native";const schema = i.schema({entities: {$files: i.entity({path: i.string().unique(),url: i.any().optional(),}),$users: i.entity({email: i.string().unique(),}),},links: {},rooms: {},});type AppSchema = typeof schema;export type { AppSchema };export default schema;
Aptly titled, you can manage your appās schema in instant.schema.ts.
Youāll notice $users and $files. These are built-in entities in Instant: when a user signs up weāll create a row in $users. When you upload a file weāll create a row in $files. So what should happen when someone wants to send an emoji?
The messages entity
Well, letās create a row in messages. To do that, we can need to add a messages entity to the schema:
import { i } from "@instantdb/react-native";import { Emoji } from "@/components/emoji";const schema = i.schema({entities: {// ...messages: i.entity({body: i.string<Emoji>(),createdAt: i.date(),}),},links: {},rooms: {},});// ..export default schema;
This looks good. Now run push, and see your backend update!
-Ā npx instant-cli pushChecking for an Instant SDK...Found @instantdb/react-native in your package.json.Found EXPO_PUBLIC_INSTANT_APP_ID: d3aecdf7-e27f-4fc7-85ef-c7c772f85f1ePlanning schema...The following changes will be applied to your production schema:ADD ENTITY messages.idADD ATTR messages.body :: unique=false, indexed=falseADD ATTR messages.createdAt :: unique=false, indexed=false? OK to proceed? yesSchema updated!Planning perms...No perms changes detected. Skipping.
If you check the Explorer in your Instant Dashboard, youāll see messages!
Goal 4: Connect your app
Okay, we have a backend, now letās connect our frontend! Hereās what we need to do.
db.ts
Head on over and create a src/db.ts file. This is where weāll connect to Instant:
import { init } from "@instantdb/react-native";import schema from "instant.schema";const db = init({appId: process.env.EXPO_PUBLIC_INSTANT_APP_ID,schema: schema,});export default db;
Aand thatās it. We can use db to make queries and transactions. When we do, our data will persist, queries will work offline, and everything will be multiplayer!
Update index.ts
Letās put db to the test. Go ahead and update src/app/index.tsx. First, weāll import our db and Instantās handy id function:
import db from "@/db";import { id } from "@instantdb/react-native";
We can replace our useState with a query for messages:
export default function Page() {- const [messages, setMessages] = useState<Message[]>([]);+ const { data } = db.useQuery({ messages: {} });+ const messages = data?.messages || [];
And we can use db.transact to create messages:
- setMessages((prev) => {- return [- ...prev,- {- id: prev.length.toString(),- body: emoji,- createdAt: Date.now(),- },- ];- });+ db.transact(+ db.tx.messages[id()].update({+ body: emoji,+ createdAt: Date.now(),+ })+ );
Put it all together, and hereās how src/app/index.tsx looks:
import {EMOJI_GAP,EmojiBar,EmojiPill,EMOJIS,emojiOffset,} from "@/components/emoji";import { SafeAreaView, ScrollView, View } from "react-native";import { ScaleButton } from "../components/ScaleButton";import db from "@/db";import { id } from "@instantdb/react-native";export default function Page() {const { data } = db.useQuery({ messages: {} });const messages = data?.messages || [];return (<SafeAreaView className="flex-1 m-4"><ScrollView className="py-4">{messages.toReversed().map((msg) => {const marginLeft = emojiOffset(msg.body);return (<View className="mb-4" key={msg.id}><EmojiBar><View style={{ marginLeft }}><EmojiPill label={msg.body} /></View></EmojiBar></View>);})}</ScrollView><EmojiBar>{EMOJIS.map((emoji, idx) => (<ScaleButtonkey={emoji}onPress={() => {db.transact(db.tx.messages[id()].update({body: emoji,createdAt: Date.now(),}));}}><View style={{ marginLeft: idx == 0 ? 0 : EMOJI_GAP }}><EmojiPill label={emoji} /></View></ScaleButton>))}</EmojiBar></SafeAreaView>);}
The moment of truthā¦
Load Expo Go, and you have a multiplayer EmojiChat! If you look at your Explorer, youāll see emojis update in real-time too:
Wow. Letās pause for a moment.
You now have an app with a full backend!
As weād say in EmojiChat, š»š»š„šš
Goal 5: Locking things down with permissions
You may think to yourself, weāve saved data, but what about permissions? What if a hacker sniffs network traffic and saves a āš©ā emoji? Or what if they try to delete all messages?
This is where permissions come in. Head on over and take a look at instant.perms.ts:
// Docs: <https://www.instantdb.com/docs/permissions>import type { InstantRules } from "@instantdb/react-native";const rules = {/*** Welcome to Instant's permission system!* Right now your rules are empty. To start filling them in, check out the docs:* <https://www.instantdb.com/docs/permissions>** Here's an example to give you a feel:* posts: {* allow: {* view: "true",* create: "isOwner",* update: "isOwner",* delete: "isOwner",* },* bind: ["isOwner", "auth.id != null && auth.id == data.ownerId"],* },*/} satisfies InstantRules;export default rules;
Instant comes with permissions baked in. You can define permission rules that run for every object returned in a query or changed in a transaction.
Disable deletes
For example, to disable deletes, we could write:
// Docs: <https://www.instantdb.com/docs/permissions>import type { InstantRules } from "@instantdb/react-native";const rules = {messages: {allow: {delete: "false",},},} satisfies InstantRules;export default rules;
This defaults the delete rule for messages to false. Once we push it:
-Ā npx instant-cli pushChecking for an Instant SDK...Found @instantdb/react-native in your package.json.Found EXPO_PUBLIC_INSTANT_APP_ID: d3aecdf7-e27f-4fc7-85ef-c7c772f85f1ePlanning schema...No schema changes detected. Skipping.Planning perms...The following changes will be applied to your perms:{+ messages: {+ allow: {+ delete: "false"+ }+ }}? OK to proceed? yesPermissions updated!
If any nefarious hacker tries to delete an object, theyāre out luck!
Locking down emojis
Letās get to the š© problem. What if someone tries to hack the system and send us a āš©ā? We can add a rule for that too:
import type { InstantRules } from "@instantdb/react-native";const rules = {messages: {allow: {delete: "false",update: "false",create: 'data.body in ["š„", "ā¤ļø", "š»", "š"]',},},} satisfies InstantRules;export default rules;
This makes sure that you can only create a message with a valid emoji, and you canāt change an emoji after you create it. If we push it:
Checking for an Instant SDK...Found @instantdb/react-native in your package.json.Found EXPO_PUBLIC_INSTANT_APP_ID: d3aecdf7-e27f-4fc7-85ef-c7c772f85f1ePlanning schema...No schema changes detected. Skipping.Planning perms...The following changes will be applied to your perms:{messages: {allow: {+ create: "data.body in [\\"š„\\", \\"ā¤ļø\\", \\"š»\\", \\"š\\"]"+ update: "false"}}}? OK to proceed? yesPermissions updated!
Locking down everything
Weāve anticipated a few things a hacker could change, but what about stuff we havenāt thought about? To be on the safer side, we can lock down the database completely, so everything that we havenāt been explicit about gets denied:
import type { InstantRules } from "@instantdb/react-native";const rules = {$default: {allow: {$default: "false",},},messages: {allow: {view: "true",delete: "false",update: "false",create: 'data.body in ["š„", "ā¤ļø", "š»", "š"]',},},} satisfies InstantRules;export default rules;
The $default rule says, if thereās some object that we havenāt made a rule for, weāll make sure itās false. Thereās so much more you can do, just check out the permissions docs.
Fin
Heck yeah! Weāve just created a chat app for iOS, Android, and web. It works offline, is multiplayer by default and comes with permissions. This is what happens when you use tools that help you get into flow and blast through schleps.
Whatās next?
You now have a pretty cool app, but thereās more we could do!
Sometimes emojis donāt quite cut it. What if we let you express yourself with your thumbs? We could add finger kisses:
And right now we just have one giant arena for all conversations. What if we added Auth and channels? You can start thinking about how to do this by reading the Instant docs (one, two), but we hope to continue the tutorial another day too š.



