Build a React app with SkyState
Build a React app one feature at a time. You start with a public welcome message, then add sign-in, user state, notes, and a settings form that holds edits until Save.
Public state is the part of SkyState anyone can read without signing in. User state belongs to each signed-in user. Drafts hold user-state edits on the page before saving them.
Estimated time: 60 minutes.
What you'll build
A React app backed by @skystate/react:
- A welcome heading read from public state.
- Sign-in.
- A counter stored separately for each signed-in user.
- A notes list for each signed-in user.
- A settings form with Save and Discard.
Prerequisites
Node.js 20.19 or later, or 22.12 or later (npm ships with it). Vite's scaffold needs one of these; older 20.x releases fail at npm create vite.
Part 1: Read a public value
Start with a value every visitor can read: a welcome message stored as SkyState public state.
Step 1: Scaffold a React app
You need a plain React app to add SkyState to. Create one with Vite:
bash
npm create vite@latest tutorial-app -- --template react-ts --no-interactive --no-immediateMove in, install dependencies, and add the SkyState React package:
bash
cd tutorial-app
npm install
npm install @skystate/reactStep 2: Set up the CLI and project
Your app reads from a SkyState project, and the provider needs your account ID. You create both from the CLI.
Install the CLI globally so the sky command is on your path:
bash
npm install -g skystateSign in:
bash
sky loginThis opens SkyState's sign-in page in your browser. Sign in with Google or GitHub. The first run creates your account, and the CLI keeps you signed in for the commands that follow.
A SkyState project is the container your state lives in, and one project serves every client that reads it, whether that is this app, a script, or a curl command. Create yours with a display name and a slug:
bash
sky project create "Tutorial App" tutorial-appThe slug identifies the project in the SDK and in every later command that passes --project.
Get your account ID:
bash
sky statusFind the Login block and copy ACCOUNT ID, which looks like acc_.... You pass it to the provider next.
INFO
You can also create and inspect projects in the SkyState console. This tutorial uses the CLI.
Step 3: Wrap App in the provider
The hooks read their data through a SkyState client, and they get that client from the provider. Put <App /> inside SkyStateProvider.
Add the import to src/main.tsx:
tsx
import { SkyStateProvider } from '@skystate/react'Wrap <App /> with the provider, inside the existing createRoot(...).render(...):
tsx
<SkyStateProvider
account="YOUR_ACCOUNT_ID"
project="tutorial-app"
environment="development"
>
<App />
</SkyStateProvider>Replace YOUR_ACCOUNT_ID with the account ID from sky status.
Step 4: Show the welcome text
The Vite scaffold renders a static Get started heading in src/App.tsx. Point it at the SkyState value instead.
Add the import alongside the existing imports at the top of src/App.tsx:
tsx
import { usePublicState } from '@skystate/react'Read the value inside App, next to the existing useState:
tsx
const { value: welcome } = usePublicState('welcomeText', 'Hello!')Change the scaffold's <h1>Get started</h1> to render the value:
tsx
<h1>{welcome}</h1>The second argument to usePublicState is a fallback. Until the stored value loads, or before you have ever pushed one, the value is Hello!. Because you gave a fallback, the value is always a string, so the component never handles a missing or loading case.
Step 5: Run the app
bash
npm run dev -- --port 5173 --strictPortOpen http://localhost:5173/. The heading reads Hello!, the fallback, since you have not pushed a value yet.
Leave this server running. The sky commands in the rest of the tutorial go in a second terminal.
Verify
You have a page reading from SkyState. Now change the value from the command line and watch the page follow.
In a second terminal, push the public state. The --comment flag records why you created this version:
bash
sky state public push \
--json '{"welcomeText":"Hello SkyState!"}' \
--project tutorial-app \
--env development \
--comment "Set welcome message"Reload the page. The heading now reads Hello SkyState!
That is the update loop: change the value from the command line or the console, reload, see the change. No deploy.
Part 1 recap: what changed
tsx
1import { useState } from 'react'
2import reactLogo from './assets/react.svg'
3import viteLogo from './assets/vite.svg'
4import heroImg from './assets/hero.png'
+import { usePublicState } from '@skystate/react'
5import './App.css'
6
7function App() {
8 const [count, setCount] = useState(0)
+ const { value: welcome } = usePublicState('welcomeText', 'Hello!')
9
10 return (
11 <>
⋯
18 <div>
19- <h1>Get started</h1>
+ <h1>{welcome}</h1>
20 <p>Part 2: Add sign-in and user state
User state belongs to each signed-in user. In this part we add sign-in, and save a count value for each user stored in SkyState.
Step 1: Enable sign-in
SkyState runs a hosted sign-in page for your app. Google and GitHub are enabled by default, and you can brand the sign-in page from the SkyState console. To use it locally on the dev environment, register the app's callback URL and enable sign-in for the project through the cli.
After sign-in succeeds, SkyState redirects the browser back to your app. A callback URL is an allowed return address for that redirect. Registering it tells SkyState which local or deployed URLs are allowed for this project and environment.
Register the local Vite app as an allowed callback URL, then enable sign-in for the project:
bash
sky project auth callback-urls add \
--project tutorial-app \
--env development \
--url http://localhost:5173
sky project auth enable --project tutorial-appThe root URL works because the provider is mounted on the page that receives the sign-in callback. No separate callback route is needed for this tutorial app.
Step 2: Add a login button in the top-right corner
This step adds a login button pinned to the top-right corner of the page. The login button needs to know whether the visitor is signed in, and it needs actions for starting login or signing out. useAuth gives the component both. Add it to the @skystate/react import in src/App.tsx:
tsx
import { useAuth, usePublicState } from '@skystate/react'Inside App, find the welcome value from Part 1 and add auth directly below it. One button covers both directions, so derive its label and click handler from auth.status right here:
tsx
const { value: welcome } = usePublicState('welcomeText', 'Hello!')
const auth = useAuth()
const signedIn = auth.status === 'authenticated'
const authLabel = signedIn ? 'Sign out' : 'Sign in'
const onAuthClick = () => void (signedIn ? auth.logout() : auth.loginWithRedirect())Add the button as the first child inside the returned <>. It pins itself to the top-right corner with position: 'fixed':
tsx
<>
<button
className="counter"
style={{ position: 'fixed', top: '1rem', right: '1rem' }}
onClick={onAuthClick}
>
{authLabel}
</button>
...loginWithRedirect() sends the user to the SkyState sign-in page. After sign-in succeeds, SkyState redirects back to the callback URL you registered with sky project auth callback-urls add.
Step 3: Store a personal counter
The counter needs to read and save a value that belongs to the signed-in user. useUserState reads that personal state and gives you a setter for updating it. Add it to the SkyState import:
tsx
import { useAuth, usePublicState, useUserState } from '@skystate/react'Inside App, replace the scaffold's local counter state:
tsx
const [count, setCount] = useState(0)with the saved user counter:
tsx
const { value: count, set: setCount } = useUserState('count', 0)The Vite counter is no longer local React state, so delete the useState import:
tsx
import { useState } from 'react'The scaffold's counter button already calls setCount and shows Count is {count}, so its JSX needs no change. setCount from useUserState takes the same (prev) => next updater the scaffold uses, so onClick={() => setCount((count) => count + 1)} works unchanged. The fallback 0 means the counter shows a number before any saved value exists, including before sign-in.
Because the count lives in SkyState rather than local React state, it survives a page refresh. Each click saves the new value through useUserState, and on the next load the hook refetches it for the signed-in user, so the counter comes back at the number you left it.
Verify
With the dev server from Part 1 still running, open http://localhost:5173/, sign in, click the counter button. When you refresh the page the count will remain.
For the second check, sign out and sign in as another account. That user should start at 0, because each signed-in user has their own count.
Part 2 recap: what changed
tsx
1-import { useState } from 'react'
2import reactLogo from './assets/react.svg'
3import viteLogo from './assets/vite.svg'
4import heroImg from './assets/hero.png'
5-import { usePublicState } from '@skystate/react'
+import { useAuth, usePublicState, useUserState } from '@skystate/react'
6import './App.css'
7
8function App() {
9- const [count, setCount] = useState(0)
+ const { value: count, set: setCount } = useUserState('count', 0)
10 const { value: welcome } = usePublicState('welcomeText', 'Hello!')
+ const auth = useAuth()
+ const signedIn = auth.status === 'authenticated'
+ const authLabel = signedIn ? 'Sign out' : 'Sign in'
+ const onAuthClick = () => void (signedIn ? auth.logout() : auth.loginWithRedirect())
11
12 return (
13 <>
+ <button
+ className="counter"
+ style={{ position: 'fixed', top: '1rem', right: '1rem' }}
+ onClick={onAuthClick}
+ >
+ {authLabel}
+ </button>
14 <section id="center">Part 3: Save notes for each user
Part 2 saved a single number for each user. A notes list is the next step up: many values, added and removed over time. You will build the list in plain React first, watch a refresh wipe it, then persist it with useUserState so it belongs to the signed-in user.
Step 1: Build the notes component with local state
Start with an ordinary React component, no SkyState yet: a useState list, an input, and add and remove buttons. Create src/Notes.tsx:
tsx
import { useState, type CSSProperties } from 'react'
const inputStyle: CSSProperties = {
font: 'inherit',
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--text-h)',
}
const noteStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 12px',
borderRadius: 6,
background: 'var(--social-bg)',
border: '1px solid var(--border)',
}
type Note = { id: string; text: string }
export function Notes() {
const [notes, setNotes] = useState<Note[]>([])
const [text, setText] = useState('')
const addNote = () => {
const note: Note = { id: crypto.randomUUID(), text: text.trim() || 'Empty note' }
setNotes((prev) => [...prev, note])
setText('')
}
return (
<>
<h2>Notes</h2>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="Write a note"
style={{ ...inputStyle, flex: 1 }}
/>
<button type="button" className="counter" style={{ margin: 6 }} onClick={addNote}>
Add
</button>
</div>
<ul style={{ flexDirection: 'column' }}>
{notes.map((note) => (
<li key={note.id} style={noteStyle}>
{note.text}
<button
type="button"
className="counter"
style={{ margin: 0 }}
onClick={() => setNotes((prev) => prev.filter((n) => n.id !== note.id))}
>
Remove
</button>
</li>
))}
</ul>
</>
)
}One useState holds the saved list, another holds the text being typed. Each note is an object with its own id and text. addNote trims the input, falls back to 'Empty note' when it is blank, gives the note an id, appends it, and clears the field. Each setter builds a new array, [...prev, note] to add and prev.filter(...) to remove, rather than changing the array in place.
INFO
Remove by id, not by position. Each row's key and its Remove button both use note.id, so prev.filter((n) => n.id !== note.id) drops exactly the one you clicked even when the saved list has changed since the page rendered. That matters once the list is shared user state in Step 3, where a functional setter can run against a newer list than the one on screen.
Step 2: Show notes in your app
App is where your features come together, so render the notes component there. Add the import to src/App.tsx:
tsx
import { Notes } from './Notes'The Vite starter ends with a two-column section of placeholder cards, Documentation and Connect with us, wrapped in <section id="next-steps">. That section is where your own features go. Replace the whole placeholder section with your notes in the first column:
tsx
<section id="next-steps">
<div id="docs">
<Notes />
</div>
</section>Keep the ids. They are what the scaffold's stylesheet uses to lay the columns out side by side and draw the divider between them, so you get that layout for free.
Try it: local state does not last
Run the app, add a few notes, remove one. It works. Now reload the page. The notes are gone.
useState lives in memory for the life of the page, so a reload throws it away. It is not tied to who is signed in either: two accounts on the same machine would share one in-memory list. For notes that belong to a user and survive reloads, the list has to live in SkyState.
Step 3: Persist the list with user state
You already used useUserState for the counter in Part 2. Swap the local list over to it. Add the import:
tsx
import { useUserState } from '@skystate/react'Replace the useState list with useUserState, leaving the text state alone:
tsx
1-const [notes, setNotes] = useState<Note[]>([])
+const { value: notes, set: setNotes } = useUserState<Note[]>('notes', [])
2const [text, setText] = useState('')useUserState<Note[]>('notes', []) reads the saved list and gives you setNotes to save a new one. The fallback [] means notes is an empty array before any note exists, including before sign-in. Nothing else in the component changes: setNotes takes the same (prev) => next updater the local version used.
That functional form matters more now than it did with local state. When a save would land on a newer list than the one you started from, SkyState reapplies your change to that newer list instead of replacing it, so a fast second edit does not erase the first. Building a new array each time and removing by note.id rather than by position is what keeps that safe: an id still points at the right note after the list has changed, where a captured index would not.
Verify
Sign in, add a couple of notes, and remove one. Reload the page, and the notes come back, because they now live in SkyState under your account. Sign out and sign in as another account, and the list starts empty, because each user has their own notes.
Part 3 recap: what changed
A new file src/Notes.tsx holds the component. It started on local useState and moved to useUserState so the list persists per user:
tsx
1import { useState, type CSSProperties } from 'react'
+import { useUserState } from '@skystate/react'
⋯
35- const [notes, setNotes] = useState<Note[]>([])
+ const { value: notes, set: setNotes } = useUserState<Note[]>('notes', [])
36 const [text, setText] = useState('')In src/App.tsx you imported it and swapped the scaffold's placeholder cards for your notes:
tsx
4import { useAuth, usePublicState, useUserState } from '@skystate/react'
+import { Notes } from './Notes'
5import './App.css'
⋯
43 <section id="next-steps">
+ <div id="docs">
+ <Notes />
+ </div>
44 </section>The placeholder Documentation and Connect with us cards inside that section are gone, replaced by the notes column.
Part 4: Build a settings form
A settings field should not save on every keystroke. It should hold your edits while you type, then save them when you click Save or drop them when you click Discard. useUserState gives you a draft for exactly that: a local copy you edit freely, then keep or throw away.
Step 1: Create the settings component
You need a single field that stays editable until the user saves it. Create src/Settings.tsx:
tsx
import type { CSSProperties } from 'react'
import { useUserState } from '@skystate/react'
const inputStyle: CSSProperties = {
font: 'inherit',
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--text-h)',
}
export function Settings() {
const { draft: nickname } = useUserState<string>('nickname', '')
return (
<>
<h2>Settings</h2>
<label
style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, margin: '16px 0' }}
>
Nickname
<input
value={nickname.displayValue}
onChange={(event) => nickname.set(event.target.value)}
placeholder="Add a nickname"
style={{ ...inputStyle, alignSelf: 'stretch' }}
/>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="counter" style={{ margin: 0 }} disabled={!nickname.isPending} onClick={() => nickname.discard()}>
Discard
</button>
<button type="button" className="counter" style={{ margin: 0 }} disabled={!nickname.isPending} onClick={() => nickname.push()}>
Save
</button>
</div>
</>
)
}Same styling approach as the notes component: the scaffold's counter class on the buttons and inputStyle on the field. This reads draft from useUserState instead of value and set. The draft holds your edits on the page until you decide what to do with them:
draft.displayValueis what the input shows: your edit if you have one, otherwise the saved nickname.draft.setupdates that local copy only. It does not save.draft.push()saves the current draft.draft.discard()drops it, and the field snaps back to the saved value.draft.isPendingistruewhile an unsaved edit exists. Save and Discard readdisabled={!nickname.isPending}, so they stay disabled until you type.
INFO
isPending means a local edit is waiting, not that a save is in flight. The form starts with no draft, so both buttons start disabled. The first keystroke creates the draft and enables them, and Save or Discard clears it.
Step 2: Show settings in your app
Render the settings component next to your notes. Add the import to src/App.tsx:
tsx
import { Settings } from './Settings'Add a second column to the #next-steps section, after the notes column:
tsx
<section id="next-steps">
<div id="docs">
<Notes />
</div>
<div>
<Settings />
</div>
</section>Only the first column needs the docs id; that is what draws the divider. The second column gets its width and padding from the section itself.
Verify
Sign in and type in the field. Save and Discard become enabled. Click Discard, and the field reverts to the saved value. Type again and click Save: the buttons disable again right away because nothing is pending. Reload, and the saved nickname comes back.
Part 4 recap: what changed
A new file src/Settings.tsx holds the form. In src/App.tsx you imported it and added it as the second column next to your notes:
tsx
4import { useAuth, usePublicState, useUserState } from '@skystate/react'
5import { Notes } from './Notes'
+import { Settings } from './Settings'
6import './App.css'
⋯
43 <div id="docs">
44 <Notes />
45 </div>
+ <div>
+ <Settings />
+ </div>
46 </section>What you learned
- Public state holds JSON values any visitor can read.
usePublicState('welcomeText', 'Hello!')returns the saved value or your fallback. SkyStateProvidersupplies the client every SkyState hook uses, scoped to one account, project, and environment.- SkyState hosts the sign-in page.
useAuthtells you whether someone is signed in and starts or ends their session. - User state belongs to each signed-in user.
useUserState('count', 0)reads and saves a value that survives reloads and stays separate for each account. - A functional setter like
setNotes((prev) => [...prev, note])builds each new value from the latest saved one, so rapid saves do not overwrite each other. - A draft holds edits on the page:
draft.setstages them,draft.push()saves,draft.discard()reverts, anddraft.isPendingtells you whether an unsaved edit exists.