vibes.diy/docs Home Discord
creator docs · reference · generated

useViewer Hook

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/use-viewer.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

useViewer() is a read-only window into viewer identity. The platform owns the rules — who's the owner, who has been granted read or write — and useViewer() lets your app see who's signed in and render identity. You cannot grant or revoke access from code; you can only reflect the runtime's verdict in your UI.

The current viewer's pill is system chrome — you don't build it. The platform shows the current user inside the Vibes Switch, the panel that opens when you click the logo. So your app does not need a header pill for whoever is looking — don't render a no-prop <ViewerTag /> just to show "who's signed in".

The sign-in ask is yours. requestLogin() from useViewer() opens the platform sign-in UI from anywhere in your app, so you can word, place and time the ask yourself. Reach for it at the moment your app becomes worth keeping. The Switch is chrome outside your app's frame, and pointing at the logo is not a call to action — it is a scavenger hunt. See Asking a visitor to sign in below.

Use useViewer() to render other people's identity — comment authors, rosters, "added by" labels — with <ViewerTag userHandle={...} />, and to read viewer/can when your UI needs to branch on who's looking. Write surfaces are gated with useVibe(dbName).can (see use-vibe docs), not with viewer/access.*.

Basic Usage

Start with a minimal component that reads the viewer identity. You don't render a current-user pill — that lives in the Vibes Switch (click the logo). Branch on viewer for any welcome/empty copy. Note there is no sign-in button here: the ask belongs at the moment the visitor has made something worth keeping, not on arrival — see Asking a visitor to sign in below.

App.jsx

jsximport React from "react";
import { useFireproof } from "use-fireproof";
import { useViewer } from "use-vibes";
import { Stack, Text } from "@vibes.diy/pouf";

export default function App() {
  const { viewer, isViewerPending } = useViewer();

  if (isViewerPending) return null;

  return (
    <Stack gap={3}>
      {/* No current-user pill here — that's the Vibes Switch's job. */}
      {viewer ? (
        <Text>Welcome back, {viewer.displayName ?? viewer.userHandle}!</Text>
      ) : (
        <Text muted>Everything you make here is saved on this device.</Text>
      )}
    </Stack>
  );
}

What you get

  • viewer — { userHandle, displayName? } or null for anonymous visitors. Avatars are not on the payload — render them with <ViewerTag userHandle={...} />, which resolves the avatar from the handle. Don't build avatar URLs yourself.
  • isViewerPending — true while the platform is still resolving the viewer identity (e.g. on first render before the parent shell has pushed the identity update). Gate any auth-dependent UI on !isViewerPending to avoid flashing the wrong state. Once it becomes false, viewer is either populated or definitively null.
  • can(action) — membership boolean for "read"/"write"/"delete": is the viewer through the door? Access functions enforce per-document and per-database rules server-side. Prefer useVibe(dbName).can.create/edit/delete for write gating; it runs the app's access function and returns a reason.
  • viewerLapsed — true when viewer is the identity this device remembers and the platform's answer came back anonymous: the sign-in lapsed (an expired session, a cold boot before auth restores), rather than the person signing out. Keep rendering their stuff — the local data is still theirs, and writes queue locally and sync once they sign back in — and offer a quiet "Sign back in to sync" nudge wired to requestLogin(). Never fall back to the anonymous copy ("Sign in to get started") while viewerLapsed is true; that reads as if their work is gone. can() and every access gate stay closed while lapsed, so gate writes on can, not on this flag, and isViewerPending is false (lapsed is a settled state, not a loading one).
  • requestLogin() — opens the platform sign-in UI. Fire-and-forget: there is nothing to await and no result to branch on, because the new identity arrives on its own through viewer. This is how your app asks — see Asking a visitor to sign in below.
  • ViewerTag — ready-made user pill; see the ViewerTag section below.
  • HandleInput — ready-made handle-lookup text input; see the HandleInput section below.

Gating UI

Gate the comment form on useVibe("comments").can — not on viewer. The current viewer never needs a pill here (that's in the Vibes Switch); just render the gated form, and on a denial a sentence in your app's own words — reason is a machine token that informs that sentence and is never printed:

App.jsx

jsx<<<<<<< SEARCH
import { useViewer } from "use-vibes";
=======
import { useViewer, useVibe } from "use-vibes";
>>>>>>> REPLACE

App.jsx

jsx<<<<<<< SEARCH
import { Stack, Text } from "@vibes.diy/pouf";
=======
import { Button, Field, Input, Row, Stack, Text } from "@vibes.diy/pouf";
>>>>>>> REPLACE

App.jsx

jsx<<<<<<< SEARCH
  const { viewer, isViewerPending } = useViewer();
=======
  const { viewer, isViewerPending, requestLogin } = useViewer();
  const { can, ready, me } = useVibe("comments");
>>>>>>> REPLACE

App.jsx

jsx<<<<<<< SEARCH
      {viewer ? (
        <Text>Welcome back, {viewer.displayName ?? viewer.userHandle}!</Text>
      ) : (
        <Text muted>Everything you make here is saved on this device.</Text>
      )}
=======
      {/* write gate: useVibe().can, not viewer */}
      {!ready ? null : (() => {
        const v = can.create({ type: "comment", authorHandle: me?.userHandle });
        return v.ok ? (
          <form>
            <Row gap={2} align="top">
              <Field label="Comment">{(id, describedBy) => <Input id={id} describedBy={describedBy} value="" onChange={() => {}} placeholder="Add a comment..." />}</Field>
              <Button type="submit">Post</Button>
            </Row>
          </form>
        ) : me?.userHandle ? (
          // Your own words, informed by v.reason. A signed-in viewer who is denied is
          // outside this thread's membership, so the sentence says that; the sign-in
          // ask is for the anonymous viewer, and it is a control wired to requestLogin.
          <Text muted>Comments here are for members of this thread.</Text>
        ) : (
          <Button type="button" onClick={requestLogin}>Sign in to add a comment</Button>
        );
      })()}
>>>>>>> REPLACE

Tagging content with the viewer (write/render pattern)

When one user writes content others will see (comments, posts, messages), stamp authorHandle on the doc at write time. That's it — just the handle. Render with <ViewerTag userHandle={doc.authorHandle} /> which resolves display name and avatar automatically. Do not stamp displayName or avatarUrl on docs — ViewerTag handles that from the handle alone.

Wire up a full comment thread with Fireproof and viewer attribution:

App.jsx

jsx<<<<<<< SEARCH
import { Button, Field, Input, Row, Stack, Text } from "@vibes.diy/pouf";
=======
import { Button, Field, Input, Row, RowCard, Stack, Text } from "@vibes.diy/pouf";
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
  const { viewer, isViewerPending, requestLogin } = useViewer();
  const { can, ready, me } = useVibe("comments");
=======
  const { viewer, isViewerPending, ViewerTag, requestLogin } = useViewer();
  const { can, ready, me } = useVibe("comments");
  const { useLiveQuery, database } = useFireproof("comments");
  const { docs: comments } = useLiveQuery("createdAt");
  const [body, setBody] = React.useState("");
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
      {/* write gate: useVibe().can, not viewer */}
      {!ready ? null : (() => {
        const v = can.create({ type: "comment", authorHandle: me?.userHandle });
        return v.ok ? (
          <form>
            <Row gap={2} align="top">
              <Field label="Comment">{(id, describedBy) => <Input id={id} describedBy={describedBy} value="" onChange={() => {}} placeholder="Add a comment..." />}</Field>
              <Button type="submit">Post</Button>
            </Row>
          </form>
        ) : me?.userHandle ? (
          // Your own words, informed by v.reason. A signed-in viewer who is denied is
          // outside this thread's membership, so the sentence says that; the sign-in
          // ask is for the anonymous viewer, and it is a control wired to requestLogin.
          <Text muted>Comments here are for members of this thread.</Text>
        ) : (
          <Button type="button" onClick={requestLogin}>Sign in to add a comment</Button>
        );
      })()}
=======
      {/* render OTHER users with userHandle — that's what ViewerTag is for here */}
      <Stack gap={3}>
        {comments.map((c) => (
          <RowCard key={c._id}>
            <ViewerTag userHandle={c.authorHandle} />
            <Text>{c.body}</Text>
          </RowCard>
        ))}
      </Stack>

      {/* write gate: useVibe().can, not viewer */}
      {!ready ? null : (() => {
        const v = can.create({ type: "comment", authorHandle: me?.userHandle });
        return v.ok ? (
          <form onSubmit={(e) => { e.preventDefault(); post(); }}>
            <Row gap={2} align="top">
              <Field label="Comment">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={body} onChange={setBody} placeholder="Add a comment..." />}</Field>
              <Button type="submit">Post</Button>
            </Row>
          </form>
        ) : me?.userHandle ? (
          // Your own words, informed by v.reason. A signed-in viewer who is denied is
          // outside this thread's membership, so the sentence says that; the sign-in
          // ask is for the anonymous viewer, and it is a control wired to requestLogin.
          <Text muted>Comments here are for members of this thread.</Text>
        ) : (
          <Button type="button" onClick={requestLogin}>Sign in to add a comment</Button>
        );
      })()}
>>>>>>> REPLACE

Also add the post handler before if (isViewerPending):

jsx<<<<<<< SEARCH
  if (isViewerPending) return null;
=======
  async function post() {
    if (!body.trim()) return;
    setBody(""); // ready for the next post at once
    const { ok } = await database.put({
      body: body.trim(),
      createdAt: Date.now(),
      authorHandle: me?.userHandle,
    });
    if (!ok) setBody((now) => now || body);
  }

  if (isViewerPending) return null;
>>>>>>> REPLACE

Key points:

  • Stamp authorHandle at write time — persist the author's handle on the doc. Render with <ViewerTag userHandle={authorHandle} /> which resolves display name and avatar automatically.
  • Avatars are stable — ViewerTag resolves the avatar from the handle; if the author changes their avatar, the URL stays the same and the bytes update. ViewerTag handles this for you.
  • One source of identity — persist authorHandle on the doc. ViewerTag does the rest.

Asking a visitor to sign in

An anonymous visitor is having a legitimately good time. Under local-first their work is already saved on their device and your app should keep working for them — the account buys sync, followers and durability, not permission. So the ask is an offer to keep what they already have — never a wall in front of it. A visitor who never signs in should still get a working app; one who does should feel like they kept something.

Three rules separate a CTA that converts from the two shapes that don't.

Ask at the moment of value. The ask lands when the person has just made something they'd hate to lose — posted their first comment, starred their first pick, saved their first note. A prompt on arrival asks a stranger to commit before they know what the app does; a prompt after the first save offers to keep something they can already see.

Wire the ask to requestLogin(), never to a sentence about the logo. Copy like "sign in via the logo" points at platform chrome outside your app's frame and sends the reader hunting for it. It is not a call to action and it does not convert. Render a real control: <Button onClick={requestLogin}>Keep these</Button>. Every sign-in line your app renders — a muted Text, an Empty title — comes with that control right beside it, so the reader can act on it where they read it.

Only ask once identity has settled. isViewerPending is true while the platform is still resolving who's looking, and a CTA rendered in that window shows signed-in people a SIGN IN button on every load. Compute the settled-signed-out state once and gate on it — const settledSignedOut = !isViewerPending && !viewer;.

Layer it onto the comment app: the visitor can already read and post, and once they've posted, we offer to keep it. requestLogin already comes from useViewer() above.

jsx<<<<<<< SEARCH
  const [body, setBody] = React.useState("");
=======
  const [body, setBody] = React.useState("");

  // Settled AND anonymous — never merely anonymous, or signed-in viewers flash
  // this on every load while their identity is still resolving (#3639).
  const settledSignedOut = !isViewerPending && !viewer;
  // The moment of value: a comment THIS visitor wrote. An anonymous write
  // stamps `authorHandle: me?.userHandle` with `me` still null, so an undefined
  // author is our own unclaimed work — which is exactly what signing in keeps.
  // Reading `comments.length` instead would nag people who only ever read.
  const hasSomethingToKeep = comments.some((c) => c.authorHandle === undefined);
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
      {/* write gate: useVibe().can, not viewer */}
=======
      {settledSignedOut && hasSomethingToKeep && (
        <Stack gap={2}>
          {/* Says what they GET. Never "you must sign in to continue" — they
              can continue, and their comment above is already saved locally.
              And never "post it under your name": signing in syncs the comment
              they already wrote, it does not rewrite the author stamped on it.
              Promise continuity, which is true — not attribution, which isn't. */}
          <Text muted>Your comment is saved on this device.</Text>
          <Row>
            <Button type="button" onClick={requestLogin}>Sign in to keep it on all your devices</Button>
          </Row>
        </Stack>
      )}

      {/* write gate: useVibe().can, not viewer */}
>>>>>>> REPLACE

Copy that works, and copy that doesn't:

Write this Not this
"Sign in to sync your picks everywhere" "Sign in to continue"
"Keep these on all your devices" "You must sign in to save"
"Saved on this device — sign in to sync" "Sign in via the Vibes DIY logo"

And never disable the app's real controls on !viewer. If favoriting works signed out, keep it working signed out — the access function refuses the cloud write on its own, calmly, while the local write still succeeds. Turning a working app into a signup wall is worse than never asking at all.

Keep the guest's composed text across sign-in. Draft input lives in component state (or a local draft the app already saves) — and signing in never clears it. The new identity arrives on its own through viewer; the words they typed stay where they left them, and they continue from there. A form that resets on identity change makes the guest write everything twice.

Notes

  • Never use Clerk user IDs. Only userHandle crosses into vibe code.
  • Avatar URLs are stable indirection URLs — when a user changes their avatar, the URL stays the same and the bytes update. Treat them as opaque strings.
  • To reflect a viewer's roles/channels for display, use access from useFireproof(): access.hasRole("moderator"), access.hasChannel("engineering"). To gate a write surface, use useVibe(dbName).can (it runs the same access.js). The access function is the server-side authority either way.

ViewerTag

ViewerTag is a ready-made inline user pill. Import it directly with import { ViewerTag } from "use-vibes";, or get it alongside viewer from useViewer(). Both forms use the current viewer context and the same identity, pending, and avatar-edit behavior. Use it to render other people — comment authors, roster rows, "added by" labels — by passing userHandle:

jsx{/* Show another user read-only: */}
<ViewerTag userHandle={comment.authorHandle} />
{/* Placement override — className carries placement only: */}
<ViewerTag userHandle={member.userHandle} className="mt-1" />

Tapping a tag opens that handle's profile. Anywhere a ViewerTag shows, a tap opens that handle's profile — pass onClick and bind the handle the tag displays. The app owns the navigation: it binds the displayed handle and chooses the profile view.

jsxconst { ViewerTag } = useViewer();
const [profileHandle, setProfileHandle] = React.useState(null);

{/* Pass the handle you render: a tap opens that handle's profile. */}
<ViewerTag userHandle={member.userHandle} onClick={() => setProfileHandle(member.userHandle)} />
{profileHandle && <ProfileView handle={profileHandle} />}

Opening a profile follows nobody and fetches nothing on its own — follow stays behind its own button, and the profile view reads what it needs. The tap carries the handle the tag shows, so each row opens its own person.

Hero avatars render natively. size draws the avatar circle at that diameter with the badge and initial in proportion, and avatarOnly drops the handle text — and the pill chrome with it, so the header is the circle itself — for headers, pins and stacks. Both work with and without a profile action:

jsx{/* Profile header: big circle, no handle text, still one native button. */}
<ViewerTag userHandle={profileHandle} size={120} avatarOnly onClick={() => setProfileHandle(profileHandle)} />

Give the tag its profile action and the clickable shape needs no wrapper. The tag is already one native button with its accessible name, so it answers keyboard and screen readers as written — size and avatarOnly shape the header, and a plain tag without onClick stays a quiet span.

The no-prop tag is the viewer's own photo action. A no-prop <ViewerTag /> offers its avatar upload at every size, avatar-only included — its click opens the photo picker, so onClick belongs on explicit references. An explicit userHandle is always a plain reference, even when it names the viewer.

The current viewer's pill is system chrome, not app UI. The platform already shows the current user inside the Vibes Switch that opens from the logo, so don't add a no-prop <ViewerTag /> as a header pill just to show who's signed in; reach for userHandle to render someone else instead. To ask an anonymous visitor to sign in, use requestLogin() and your own copy (see Asking a visitor to sign in) — that ask belongs at the moment of value, which is a place only your app knows.

The one in-app reason to render a no-prop <ViewerTag /> is inline avatar self-edit. A no-prop tag shows the signed-in viewer a dashed edit ring that lets them change their own avatar in place, and that works for every signed-in viewer — the Switch's avatar editing is owner-only. So if your app wants any member (not just the owner) to update their photo without leaving the app, render a guarded {viewer && <ViewerTag />} for that — otherwise leave the current-viewer pill to the Switch.

Invited people. An invite:<id> subject from HandleInput renders as an "Invited · not here yet" pill until that person arrives, and from then on as the handle they joined as — avatar and profile tap included — with no change to the stored subject.

Undefined safety. If userHandle is present in props but falsy (e.g. a missing field from a loop lookup), ViewerTag renders a dim italic placeholder instead of the edit ring. This prevents a broken data source from accidentally granting photo-edit access to an arbitrary pill.

Anonymous & always-safe. ViewerTag never throws regardless of login state. A no-prop <ViewerTag /> renders a "Sign in" button for anonymous viewers — a fine fallback, but it is an identity chip in whatever spot you put it, not a CTA you can word or time. When the goal is conversion, write the prompt yourself and wire it to requestLogin().

Theming. ViewerTag reads the canonical palette variables — --surface, --text-primary, --border, and --accent (plus optional --accent-text to override the on-accent text color) — from the app's CSS with sensible fallbacks. If your app defines these on :root (which every generated theme does), ViewerTag inherits the theme automatically with no extra props. The one exception is the signed-out Sign in CTA, which deliberately stays the Vibes brand blue: it is a platform affordance matched to the Vibes Switch shown to the same visitor, so it is not theme-inherited.

Pass <ViewerTag userHandle={...} /> to render other people. Reach for the no-prop <ViewerTag /> when you specifically want inline avatar self-edit for any signed-in member; for the current-user pill the Switch already covers it, and for a sign-in ask use requestLogin().

HandleInput

HandleInput is a ready-made find-a-person input. Import it directly with import { HandleInput } from "use-vibes", or take it from useViewer() alongside ViewerTag; both expose the same component and props. Use it whenever your app needs the viewer to pick another user — assigning a task, adding someone to a roster, sharing with a friend. Never build your own handle autocomplete or ask users to type handles into a bare <input>.

jsxconst { HandleInput } = useViewer();

// Controlled: store just the handle string on your doc.
const [assignee, setAssignee] = React.useState(null);
<HandleInput value={assignee} onChange={setAssignee} placeholder="Assign to…" />;

How it behaves:

  • Typing opens a dropdown of prefix matches. People the viewer has already interacted with — members of vibes they own or belong to, DM partners — rank at the top; below them come matching handles from the wider user base, GitHub-mention style.
  • Picking one collapses the input into a ViewerTag for that handle (avatar + name, resolved automatically), with an × to clear and pick again.
  • Raw entry always works. Pressing Enter commits the sanitized typed text even when nothing matched, so users can reference someone brand-new.
  • onChange fires with the picked handle string (or null on clear). Persist only the handle on your docs — render it later with <ViewerTag userHandle={...} />, exactly like authorHandle.
  • It also takes an email address for someone not on Vibes yet: after the viewer approves the invite, onChange receives an opaque invite:<id> subject — store it as the invitee exactly like a handle; the app never holds the address.
  • Works uncontrolled too (omit value) when you only need the onChange events.
  • Theming matches ViewerTag — it reads the same canonical CSS variables (--surface, --text-primary, --text-secondary, --border, --accent).
jsx// Typical write pattern: stash the picked handle on a doc.
async function assign(handle) {
  if (!handle) return;
  await database.put({ type: "assignment", taskId: task._id, assigneeHandle: handle, assignedBy: me?.userHandle });
}
<HandleInput onChange={assign} placeholder="Add a teammate…" />;

Adding a member to your own object — the canonical use

Almost every app works this way — a shared list, a board, a trip, a club's page, a tracker one person starts and later shares with a coach: each signed-in person has their own object and lets a friend into it by picking them here. The write is one small membership doc, and access.js turns that doc into the grant that gives the named person the object's channel (see access.md's per-object sharing examples). Keys on the picked handle and on the writer's own — the app owner has no special place in it.

jsxconst { HandleInput } = useViewer();
const { me, can } = useVibe("boards");
// Show the picker to whoever the access fn would accept as an inviter.
const canInvite = can.create({ type: "member", boardId: board._id, userHandle: "x", addedBy: me?.userHandle }).ok;

async function addMember(handle) {
  if (!handle) return;
  await database.put({ type: "member", boardId: board._id, userHandle: handle, addedBy: me.userHandle });
}

{canInvite && <HandleInput onChange={addMember} placeholder="Add a friend…" />}

Members render with <ViewerTag userHandle={m.userHandle} />, the same way any other stored handle does. An app the person asked to keep to themselves — "just for me", "no sharing" — builds the picker as its add-a-friend control: each person keeps their own collection, private to them, and adding a friend shares that collection with the friend it names (the access skill's rule for a collection of a person's own records). Everywhere else the picker belongs too.