Baselyra Docs

React

Nothing about React needs a Baselyra adapter — the client is a plain object with promises and callbacks. What is worth writing down is where to create it, how to track the session, and the one effect mistake everybody makes with realtime.

Setup

npm install @baselyra/client
VITE_BASELYRA_URL=https://api.example.com
VITE_BASELYRA_ANON_KEY=eyJhbGciOiJIUzI1NiJ9…
import { createClient } from '@baselyra/client';

export const bl = createClient(
  import.meta.env.VITE_BASELYRA_URL,
  import.meta.env.VITE_BASELYRA_ANON_KEY,
);

Export one instance and import it everywhere. Two instances means two sessions, two refresh timers racing to rotate the same refresh token, and users signed out at random.

An auth hook

import { useEffect, useState } from 'react';
import type { User } from '@baselyra/client';
import { bl } from './baselyra';

export function useAuth() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    bl.auth.getSession().then(({ data }) => {
      setUser(data.session?.user ?? null);
      setLoading(false);
    });

    const { data: { subscription } } = bl.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null);
    });
    return () => subscription.unsubscribe();
  }, []);

  return { user, loading };
}

getSession() refreshes first if the stored token has already expired, so it never hands you a token the server will reject. The listener then covers sign-in, sign-out, background renewal and profile updates.

A protected route

import { Navigate } from 'react-router-dom';
import { useAuth } from './lib/useAuth';

export function RequireAuth({ children }: { children: React.ReactNode }) {
  const { user, loading } = useAuth();
  if (loading) return <p>Loading…</p>;
  if (!user) return <Navigate to="/login" replace />;
  return <>{children}</>;
}

Reading data

import { useEffect, useState } from 'react';
import { bl } from './lib/baselyra';

export function Notes() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    bl.from('notes')
      .select('id,title,created_at')
      .order('created_at', { ascending: false })
      .abortSignal(controller.signal)
      .then(({ data, error }) => {
        if (error) {
          if (error.code === 'aborted') return;     // the component went away
          return setError(error.message);
        }
        setNotes(data ?? []);
      });

    return () => controller.abort();
  }, []);

  if (error) return <p role="alert">{error}</p>;
  return <ul>{notes.map((n) => <li key={n.id}>{n.title}</li>)}</ul>;
}

An empty list here usually is not a bug: it means row level security admits no rows for this caller. Check the policy before you check the query.

A list that stays live

useEffect(() => {
  bl.from('notes').select('id,title').order('created_at', { ascending: false })
    .then(({ data }) => setNotes(data ?? []));

  // Created inside the effect so the cleanup drops it.
  const channel = bl.channel('public:notes');
  channel.on<Note>('postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'notes' },
    ({ new: row }) => setNotes((prev) => (row ? [row, ...prev] : prev)));
  void channel.subscribe();

  return () => { void bl.removeChannel(channel); };
}, []);

Writing, and optimistic updates

async function addNote(title: string) {
  const { data, error } = await bl.from('notes').insert({ title }).select().single();
  if (error) return setError(error.message);
  // With realtime on this table the INSERT comes back over the channel too —
  // key the list by id so the two do not double up.
  setNotes((prev) => (prev.some((n) => n.id === data.id) ? prev : [data, ...prev]));
}

Do not send user_id or author from the client. Give the column a default auth.uid() and let the policy's with check refuse anything else — ownership established by the database cannot be forgotten by a component.

Uploading a file

async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
  const file = e.target.files?.[0];
  if (!file || !user) return;

  const path = user.id + '/' + file.name;
  const { error } = await bl.storage.from('avatars').upload(path, file, { upsert: true });
  if (error) return setError(error.message);

  const { data } = bl.storage.from('avatars').getPublicUrl(path);
  setAvatarUrl(data.publicUrl + '?v=' + Date.now());   // bust the immutable cache
}

A public object is served immutable with a year's max-age, so a re-upload at the same key needs a cache-busting query parameter or the browser keeps showing the old image.

Failure modes

What you seeWhyFix
Every realtime message twiceThe channel outlived a remount, or Strict Mode double-invoked the effectCreate inside the effect, removeChannel in the cleanup
[] from a table with rowsRLS admits nothing for this roleTest the policy
Users signed out at randomTwo client instances rotating the same refresh tokenOne createClient, exported
error.code === 'aborted' in the consoleA component unmounted mid-requestIgnore that code; it is not a failure
A re-uploaded image does not changeThe public URL is cached immutableAdd a version query parameter
CORS errors from the browserCORS_ORIGINS does not name your dev originAdd http://localhost:5173 on the server

Edit this page Report a problem

Esc
navigate open Esc close