Common patterns

Practical integration patterns for building Cloudillo applications.

Infinite Scroll with Actions

Load actions (posts, comments) with cursor-based pagination.

import { useApi, useInfiniteScroll, LoadMoreTrigger } from '@cloudillo/react'

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

  const { items, isLoading, sentinelRef } = useInfiniteScroll({
    fetchPage: async (cursor, limit) => {
      const result = await api.actions.listPaginated({
        type: 'POST',
        cursor,
        limit
      })
      return {
        items: result.data,
        nextCursor: result.cursorPagination?.nextCursor ?? null,
        hasMore: result.cursorPagination?.hasMore ?? false
      }
    },
    pageSize: 20
  })

  return (
    <div className="feed">
      {items.map(action => (
        <PostCard key={action.actionId} action={action} />
      ))}
      <LoadMoreTrigger ref={sentinelRef} isLoading={isLoading} />
    </div>
  )
}

Collaborative Document Editor

Set up a collaborative editing session with CRDT sync.

import { useCloudilloEditor } from '@cloudillo/react'
import { useEffect, useRef } from 'react'
import Quill from 'quill'
import { QuillBinding } from 'y-quill'

function CollaborativeEditor() {
  const { yDoc, provider, synced, ownerTag, fileId } = useCloudilloEditor('quillo')
  const editorRef = useRef<HTMLDivElement>(null)
  const quillRef = useRef<Quill | null>(null)

  useEffect(() => {
    if (!synced || !editorRef.current) return

    // Initialize Quill editor
    const quill = new Quill(editorRef.current, {
      theme: 'snow',
      modules: { toolbar: true }
    })
    quillRef.current = quill

    // Bind to Yjs document
    const yText = yDoc.getText('content')
    const binding = new QuillBinding(yText, quill, provider.awareness)

    return () => {
      binding.destroy()
      quill.disable()
    }
  }, [synced, yDoc, provider])

  if (!synced) {
    return <LoadingSpinner />
  }

  return (
    <div>
      <div className="editor-header">
        <span>Editing: {fileId}</span>
        <span>Owner: {ownerTag}</span>
      </div>
      <div ref={editorRef} />
    </div>
  )
}

Toast Notifications

Show feedback for user actions.

import { useToast, ToastContainer, Button } from '@cloudillo/react'

// In your app root
function App() {
  return (
    <>
      <ToastContainer position="bottom-right" />
      <MainContent />
    </>
  )
}

// In any component
function SaveButton({ data }) {
  const { api } = useApi()
  const toast = useToast()

  const handleSave = async () => {
    try {
      await api.files.update(data.fileId, data)
      toast.success('Changes saved successfully')
    } catch (err) {
      if (err.code === 'E-AUTH-UNAUTH') {
        toast.error('Session expired. Please log in again.')
      } else {
        toast.error('Failed to save changes')
      }
    }
  }

  return <Button onClick={handleSave}>Save</Button>
}

File Upload with Progress

Upload files with variant generation.

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

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

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

    setUploading(true)
    try {
      // Upload with preset for automatic variant generation
      const result = await api.files.uploadBlob(
        'gallery',      // Preset: generates thumbnail + SD variants
        file.name,
        file,
        file.type
      )

      onUploaded({
        fileId: result.fileId,
        thumbnailId: result.variantId  // Thumbnail variant ID
      })
    } catch (err) {
      console.error('Upload failed:', err)
    } finally {
      setUploading(false)
    }
  }

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={handleFileSelect}
        disabled={uploading}
      />
      {uploading && <LoadingSpinner />}
    </div>
  )
}

Real-Time Presence

Show who’s currently viewing/editing.

import { useCloudilloEditor, AvatarGroup, Avatar } from '@cloudillo/react'
import { useEffect, useState } from 'react'

function PresenceIndicator() {
  const { provider } = useCloudilloEditor('my-app')
  const [users, setUsers] = useState<Map<number, any>>(new Map())

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

    const updateUsers = () => {
      setUsers(new Map(provider.awareness.getStates()))
    }

    provider.awareness.on('change', updateUsers)
    updateUsers()

    return () => {
      provider.awareness.off('change', updateUsers)
    }
  }, [provider])

  const otherUsers = Array.from(users.entries())
    .filter(([clientId]) => clientId !== provider?.awareness.clientID)
    .map(([, state]) => state.user)

  return (
    <AvatarGroup max={5}>
      {otherUsers.map((user, i) => (
        <Avatar key={i} name={user.name} src={user.profilePic} size="sm" />
      ))}
    </AvatarGroup>
  )
}

Role-Based UI

Show different UI based on user roles.

import { useAuth } from '@cloudillo/react'
import { ROLE_LEVELS, CommunityRole } from '@cloudillo/types'

function hasRole(userRoles: string[] | undefined, requiredRole: CommunityRole): boolean {
  if (!userRoles?.length) return false
  const userLevel = Math.max(...userRoles.map(r => ROLE_LEVELS[r as CommunityRole] ?? 0))
  return userLevel >= ROLE_LEVELS[requiredRole]
}

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

  const canModerate = hasRole(auth?.roles, 'moderator')
  const canAdmin = hasRole(auth?.roles, 'leader')

  return (
    <div>
      <h2>Community Settings</h2>

      {/* Everyone can see */}
      <GeneralSettings />

      {/* Moderators and above */}
      {canModerate && <ModerationPanel />}

      {/* Leaders only */}
      {canAdmin && <AdminPanel />}
    </div>
  )
}

Optimistic Updates

Update UI immediately while syncing in background.

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

function LikeButton({ actionId, initialLiked, initialCount }) {
  const { api } = useApi()
  const toast = useToast()
  const [liked, setLiked] = useState(initialLiked)
  const [count, setCount] = useState(initialCount)

  const handleLike = async () => {
    // Optimistic update
    const wasLiked = liked
    const oldCount = count
    setLiked(!liked)
    setCount(liked ? count - 1 : count + 1)

    try {
      await api.actions.addReaction(actionId, { type: 'LOVE' })
    } catch (err) {
      // Rollback on error
      setLiked(wasLiked)
      setCount(oldCount)
      toast.error('Failed to update reaction')
    }
  }

  return (
    <Button variant={liked ? 'primary' : 'ghost'} onClick={handleLike}>
      {liked ? '❤️' : '🤍'} {count}
    </Button>
  )
}

Search with debouncing to reduce API calls.

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

function SearchBox({ onResults }) {
  const { api } = useApi()
  const [query, setQuery] = useState('')
  const [loading, setLoading] = useState(false)
  const debounceRef = useRef<NodeJS.Timeout>()

  useEffect(() => {
    if (debounceRef.current) {
      clearTimeout(debounceRef.current)
    }

    if (!query.trim()) {
      onResults([])
      return
    }

    debounceRef.current = setTimeout(async () => {
      setLoading(true)
      try {
        const results = await api.profiles.list({ q: query, limit: 10 })
        onResults(results)
      } finally {
        setLoading(false)
      }
    }, 300)

    return () => {
      if (debounceRef.current) {
        clearTimeout(debounceRef.current)
      }
    }
  }, [query, api, onResults])

  return (
    <Input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search profiles..."
      icon={loading ? <LoadingSpinner size="sm" /> : <SearchIcon />}
    />
  )
}

Connection Request Flow

Handle bi-directional connection requests.

import { useApi, useToast, Button, ProfileCard } from '@cloudillo/react'

function ConnectionButton({ profile }) {
  const { api } = useApi()
  const toast = useToast()

  const handleConnect = async () => {
    try {
      await api.actions.create({
        type: 'CONN',
        subject: profile.idTag,
        content: 'Would love to connect!'
      })
      toast.success('Connection request sent')
    } catch (err) {
      toast.error('Failed to send request')
    }
  }

  const handleAccept = async (actionId: string) => {
    try {
      await api.actions.accept(actionId)
      toast.success('Connection accepted')
    } catch (err) {
      toast.error('Failed to accept connection')
    }
  }

  // Render based on connection state
  if (profile.connected === true) {
    return <Badge variant="success">Connected</Badge>
  }

  if (profile.connected === 'R') {
    return <Badge variant="info">Request Pending</Badge>
  }

  return <Button onClick={handleConnect}>Connect</Button>
}

Error Boundary Pattern

Handle errors gracefully in components.

import { Component, ReactNode } from 'react'
import { EmptyState, Button } from '@cloudillo/react'

interface Props {
  children: ReactNode
  fallback?: ReactNode
}

interface State {
  hasError: boolean
  error?: Error
}

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  handleRetry = () => {
    this.setState({ hasError: false, error: undefined })
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <EmptyState
          icon={<AlertIcon />}
          title="Something went wrong"
          description={this.state.error?.message}
          action={<Button onClick={this.handleRetry}>Try Again</Button>}
        />
      )
    }

    return this.props.children
  }
}

// Usage
function App() {
  return (
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  )
}

See Also