@cloudillo/react

Overview

React hooks and components for integrating Cloudillo into React applications.

Installation

pnpm add @cloudillo/react @cloudillo/core

Hooks

useAuth()

Access authentication state using Jotai atoms. Returns a tuple [auth, setAuth] for reading and updating auth state.

import { useAuth } from '@cloudillo/react'

function UserInfo() {
  const [auth, setAuth] = useAuth()

  if (!auth?.idTag) {
    return <div>Not authenticated</div>
  }

  return (
    <div>
      <p>User: {auth.idTag}</p>
      <p>Name: {auth.name}</p>
      <p>Tenant: {auth.tnId}</p>
      <p>Roles: {auth.roles?.join(', ')}</p>
      {auth.profilePic && <img src={auth.profilePic} alt="Profile" />}
    </div>
  )
}

Returns: [AuthState | undefined, SetAtom<AuthState>]

interface AuthState {
  tnId: number          // Tenant ID
  idTag?: string        // Identity tag (e.g., "alice.cloudillo.net")
  name?: string         // Display name
  profilePic?: string   // Profile picture ID
  roles?: string[]      // Community roles
  token?: string        // JWT access token
}

Usage patterns:

// Destructure the tuple
const [auth, setAuth] = useAuth()

// Check if user is authenticated
if (!auth?.idTag) {
  return <LoginPrompt />
}

// Check for specific role
if (auth?.roles?.includes('admin')) {
  return <AdminPanel />
}

// Update auth state (typically done by useCloudillo)
setAuth({
  tnId: 123,
  idTag: 'alice.cloudillo.net',
  token: 'jwt-token'
})

useApi()

Get a type-safe API client with automatic caching per idTag/token combination.

import { useApi } from '@cloudillo/react'
import { useEffect, useState } from 'react'

function PostsList() {
  const { api, authenticated, setIdTag } = useApi()
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    if (!api) return

    api.actions.list({ type: 'POST', limit: 20 })
      .then(setPosts)
      .finally(() => setLoading(false))
  }, [api])

  if (!api) return <div>No API client (no idTag)</div>
  if (!authenticated) return <div>Please log in</div>
  if (loading) return <div>Loading...</div>

  return (
    <div>
      {posts.map(post => (
        <div key={post.actionId}>{post.content}</div>
      ))}
    </div>
  )
}

Returns:

interface ApiHook {
  api: ApiClient | null    // Type-safe API client (null if no idTag)
  authenticated: boolean   // Whether user has a token
  setIdTag: (idTag: string) => void // Set idTag for login flow
}

The API client is the same as returned by createApiClient() from @cloudillo/core.

api can be null

api is null until an idTag is available (either from auth state or set via setIdTag). Always check for null before using.

useCloudillo()

Unified hook for microfrontend app initialization. Handles shell communication, authentication, and document context.

import { useCloudillo } from '@cloudillo/react'

function MyApp() {
  const { token, ownerTag, fileId, idTag, roles, access, displayName } = useCloudillo('my-app')

  if (!token) return <div>Loading...</div>

  return (
    <div>
      <p>Document: {fileId}</p>
      <p>Owner: {ownerTag}</p>
      <p>Access: {access}</p>
      <p>User: {idTag}</p>
    </div>
  )
}

This hook:

  1. Calls getAppBus().init(appName) on mount
  2. Parses ownerTag and fileId from location.hash (format: #ownerTag:fileId)
  3. Updates auth state via useAuth()
  4. Returns combined state from bus and URL

Returns:

interface UseCloudillo {
  token?: string           // Access token
  ownerTag: string         // Owner of the current document (from URL hash)
  fileId?: string          // Current file/document ID (from URL hash)
  idTag?: string           // Current user's identity tag
  tnId?: number            // Tenant ID
  roles?: string[]         // User's roles
  access?: 'read' | 'write' // Access level to current resource
  displayName?: string     // User's display name (for anonymous guests)
}

useCloudilloEditor()

Extended hook for CRDT-based collaborative document editing. Returns everything from useCloudillo() plus Yjs document and sync state.

import { useCloudilloEditor } from '@cloudillo/react'

function CollaborativeEditor() {
  const { token, yDoc, provider, synced, ownerTag, fileId, access } = useCloudilloEditor('quillo')

  if (!synced) return <LoadingSpinner />

  // Use yDoc with your editor binding (Quill, TipTap, Monaco, etc.)
  const yText = yDoc.getText('content')

  return <QuillEditor yText={yText} provider={provider} />
}

This hook:

  1. Calls useCloudillo(appName) internally
  2. Opens CRDT connection via openYDoc() when token and docId are available
  3. Listens for sync events and notifies shell via bus.notifyReady('synced')
  4. Cleans up WebSocket provider on unmount

Returns:

interface UseCloudilloEditor extends UseCloudillo {
  yDoc: Y.Doc              // Yjs document instance
  provider?: WebsocketProvider // WebSocket sync provider
  synced: boolean          // Whether initial sync is complete
}

useInfiniteScroll()

Cursor-based infinite scroll pagination with IntersectionObserver.

import { useInfiniteScroll } from '@cloudillo/react'

function FileList() {
  const { api } = useApi()

  const { items, isLoading, hasMore, sentinelRef, prepend, reset } = useInfiniteScroll({
    fetchPage: async (cursor, limit) => {
      const result = await api.files.listPaginated({ cursor, limit })
      return {
        items: result.data,
        nextCursor: result.cursorPagination?.nextCursor ?? null,
        hasMore: result.cursorPagination?.hasMore ?? false
      }
    },
    pageSize: 30,
    deps: [folderId, sortField] // Reset when these change
  })

  return (
    <div>
      {items.map(file => <FileCard key={file.fileId} file={file} />)}
      {/* Sentinel triggers loadMore when visible */}
      <div ref={sentinelRef} />
      {isLoading && <LoadingSpinner />}
    </div>
  )
}

Options:

interface UseInfiniteScrollOptions<T> {
  fetchPage: (cursor: string | null, limit: number) => Promise<{
    items: T[]
    nextCursor: string | null
    hasMore: boolean
  }>
  pageSize?: number           // Default: 20
  deps?: React.DependencyList // Reset when these change
  enabled?: boolean           // Default: true
}

Returns:

interface UseInfiniteScrollReturn<T> {
  items: T[]                    // All loaded items
  isLoading: boolean            // Initial load in progress
  isLoadingMore: boolean        // Loading more pages
  error: Error | null           // Last fetch error
  hasMore: boolean              // More items available
  loadMore: () => void          // Manually load next page
  reset: () => void             // Reset and reload
  prepend: (items: T[]) => void // Add items to start (real-time)
  sentinelRef: RefObject<HTMLDivElement> // Attach to trigger element
}

Common Patterns

Pattern 1: Microfrontend App

The typical pattern for a Cloudillo microfrontend app:

import { useCloudillo, useApi } from '@cloudillo/react'
import { useEffect, useState } from 'react'

function App() {
  const { token, idTag, ownerTag, fileId, access } = useCloudillo('my-app')
  const { api } = useApi()
  const [data, setData] = useState(null)

  useEffect(() => {
    if (!api || !fileId) return

    api.files.getDescriptor(fileId)
      .then(setData)
      .catch(console.error)
  }, [api, fileId])

  if (!token) return <div>Initializing...</div>
  if (!data) return <div>Loading...</div>

  return (
    <div>
      <h1>{data.fileName}</h1>
      <p>Owner: {ownerTag}</p>
      <p>Access: {access}</p>
    </div>
  )
}

Pattern 2: Collaborative Editor

import { useCloudilloEditor } from '@cloudillo/react'
import { useEffect } from 'react'

function Editor() {
  const { yDoc, provider, synced, access } = useCloudilloEditor('my-editor')

  useEffect(() => {
    if (!synced) return

    const yText = yDoc.getText('content')

    // Set up your editor binding here
    // e.g., QuillBinding, TipTapExtension, etc.

    return () => {
      // Clean up binding
    }
  }, [yDoc, synced])

  if (!synced) return <div>Syncing document...</div>

  return (
    <div>
      {access === 'read' && <div className="read-only-banner">Read only</div>}
      <div id="editor-container" />
    </div>
  )
}

Pattern 3: Fetching Data with useApi

import { useApi } from '@cloudillo/react'
import { useEffect, useState } from 'react'

function Profile({ idTag }) {
  const { api } = useApi()
  const [profile, setProfile] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    if (!api) return

    api.profiles.get(idTag)
      .then(setProfile)
      .catch(setError)
  }, [api, idTag])

  if (!api) return <div>No API client</div>
  if (error) return <div>Error: {error.message}</div>
  if (!profile) return <div>Loading...</div>

  return (
    <div>
      <h1>{profile.name}</h1>
      <p>{profile.idTag}</p>
    </div>
  )
}

Pattern 4: Creating Actions

import { useApi } from '@cloudillo/react'
import { useState } from 'react'

function CreatePost() {
  const { api, authenticated } = useApi()
  const [text, setText] = useState('')
  const [posting, setPosting] = useState(false)

  if (!authenticated) return <div>Please log in to post</div>

  const handleSubmit = async (e) => {
    e.preventDefault()
    if (!api || !text.trim()) return

    setPosting(true)

    try {
      await api.actions.create({
        type: 'POST',
        content: { text }
      })
      setText('')
    } catch (error) {
      console.error('Failed to create post:', error)
    } finally {
      setPosting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="What's on your mind?"
      />
      <button type="submit" disabled={posting || !text.trim()}>
        {posting ? 'Posting...' : 'Post'}
      </button>
    </form>
  )
}

Pattern 5: Role-Based Rendering

import { useAuth } from '@cloudillo/react'

function AdminPanel() {
  const [auth] = useAuth()

  if (!auth?.roles?.includes('admin')) {
    return <div>Access denied. Admin role required.</div>
  }

  return (
    <div>
      <h1>Admin Panel</h1>
      {/* Admin-only features */}
    </div>
  )
}

Pattern 6: File Upload

import { useApi } from '@cloudillo/react'
import { useState } from 'react'

function ImageUpload() {
  const { api } = useApi()
  const [uploading, setUploading] = useState(false)
  const [result, setResult] = useState(null)

  const handleFileChange = async (e) => {
    const file = e.target.files?.[0]
    if (!file || !api) return

    setUploading(true)

    try {
      // Upload the file
      const uploaded = await api.files.uploadBlob(
        'gallery',      // preset
        file.name,      // fileName
        file,           // file data
        file.type       // contentType
      )

      setResult(uploaded)
    } catch (error) {
      console.error('Upload failed:', error)
    } finally {
      setUploading(false)
    }
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFileChange} />
      {uploading && <div>Uploading...</div>}
      {result && <div>Uploaded: {result.fileId}</div>}
    </div>
  )
}

Pattern 7: Real-Time Updates with RTDB

import { useApi, useAuth } from '@cloudillo/react'
import { useEffect, useState } from 'react'
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'

function TodoList({ dbFileId }) {
  const [auth] = useAuth()
  const [todos, setTodos] = useState([])

  useEffect(() => {
    if (!auth?.token || !auth?.idTag) return

    const rtdb = new RtdbClient({
      dbId: dbFileId,
      auth: { getToken: () => auth.token },
      serverUrl: getRtdbUrl(auth.idTag, dbFileId, auth.token)
    })

    const todosRef = rtdb.collection('todos')

    // Subscribe to real-time updates
    const unsubscribe = todosRef.onSnapshot((snapshot) => {
      setTodos(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })))
    })

    // Cleanup on unmount
    return () => {
      unsubscribe()
      rtdb.disconnect()
    }
  }, [auth?.token, auth?.idTag, dbFileId])

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

TypeScript Support

All hooks are fully typed:

import type { AuthState, ApiHook } from '@cloudillo/react'
import { useAuth, useApi } from '@cloudillo/react'

function MyComponent() {
  const [auth, setAuth] = useAuth()
  const { api, authenticated, setIdTag }: ApiHook = useApi()

  // TypeScript knows the types
  auth?.idTag    // string | undefined
  auth?.tnId     // number | undefined
  auth?.roles    // string[] | undefined

  api?.profiles.getOwn()  // Returns Promise<ProfileKeys>
}

See Also