@cloudillo/react

@cloudillo/react

React hooks and components for integrating Cloudillo into React applications.

Installation

pnpm add @cloudillo/react @cloudillo/base @cloudillo/types

Components

CloudilloProvider

Context provider that manages authentication state and API client for your entire app.

import { CloudilloProvider } from '@cloudillo/react'

function App() {
  return (
    <CloudilloProvider appName="my-app">
      <YourApp />
    </CloudilloProvider>
  )
}

Props:

interface CloudilloProviderProps {
  appName: string // Your app name for initialization
  children: React.ReactNode
  baseUrl?: string // Optional: API base URL
  onAuthChange?: (auth: AuthState) => void // Optional: Auth state callback
}

Example with options:

<CloudilloProvider
  appName="my-app"
  baseUrl="https://api.cloudillo.com"
  onAuthChange={(auth) => {
    console.log('Auth changed:', auth)
  }}
>
  <YourApp />
</CloudilloProvider>

MicrofrontendContainer

Component for loading microfrontend apps as iframes with postMessage communication.

import { MicrofrontendContainer } from '@cloudillo/react'

function AppShell() {
  return (
    <div className="app-container">
      <MicrofrontendContainer
        appName="quillo"
        src="/apps/quillo/index.html"
        docId="document-123"
      />
    </div>
  )
}

Props:

interface MicrofrontendContainerProps {
  appName: string // Name of the app
  src: string // URL to load in iframe
  docId?: string // Optional: Document ID to pass to app
  className?: string // Optional: CSS class
  style?: React.CSSProperties // Optional: Inline styles
  onLoad?: () => void // Optional: Called when iframe loads
}

How it works:

  1. Loads the app in an iframe
  2. Sends init message with auth credentials via postMessage
  3. Handles bidirectional communication
  4. Provides theme and dark mode info

ProfileDropdown

Pre-built user profile dropdown component.

import { ProfileDropdown } from '@cloudillo/react'

function Header() {
  return (
    <header>
      <h1>My App</h1>
      <ProfileDropdown />
    </header>
  )
}

Features:

  • Displays user profile picture and name
  • Shows dropdown menu on click
  • Links to profile, settings, logout
  • Customizable via className

Props:

interface ProfileDropdownProps {
  className?: string // Optional: CSS class
}

Hooks

useAuth()

Access authentication state from anywhere in your app.

import { useAuth } from '@cloudillo/react'

function UserInfo() {
  const auth = useAuth()

  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:

interface AuthState {
  tnId: number // Tenant ID
  idTag?: string // User identity (e.g., "alice@example.com")
  name?: string // Display name
  profilePic?: string // Profile picture URL
  roles?: string[] // User roles
  settings?: Record<string, unknown> // User settings
  token?: string // Access token
}

Usage patterns:

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

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

// Use profile picture
{auth.profilePic && <img src={auth.profilePic} />}

useApi()

Get the API client instance.

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

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

  useEffect(() => {
    api.action.get({ type: 'POST', _limit: 20 })
      .then(setPosts)
      .finally(() => setLoading(false))
  }, [api])

  if (loading) return <div>Loading...</div>

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

Returns:

const api: ApiClient

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

Utility Functions

mergeClasses(…classes: (string | undefined | null | false)[]): string

Utility for conditional CSS class names.

import { mergeClasses } from '@cloudillo/react'

function Button({ primary, disabled, className }) {
  return (
    <button
      className={mergeClasses(
        'btn',
        primary && 'btn-primary',
        disabled && 'btn-disabled',
        className
      )}
    >
      Click me
    </button>
  )
}

Common Patterns

Pattern 1: Fetching Data

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(() => {
    api.profile.get({ idTag })
      .then(setProfile)
      .catch(setError)
  }, [api, idTag])

  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 2: Creating Actions

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

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

  const handleSubmit = async (e) => {
    e.preventDefault()
    setPosting(true)

    try {
      await api.action.post({
        type: 'POST',
        content: { text }
      })
      setText('') // Clear form
      alert('Post created!')
    } catch (error) {
      alert('Failed to create post')
    } 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}>
        {posting ? 'Posting...' : 'Post'}
      </button>
    </form>
  )
}

Pattern 3: 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 4: Real-Time Updates

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

function TodoList() {
  const api = useApi()
  const [todos, setTodos] = useState([])

  useEffect(() => {
    const rtdb = new RtdbClient({
      fileId: 'my-todos',
      token: api.token,
      url: 'wss://server.com/ws/rtdb'
    })

    const todosRef = rtdb.collection('todos')

    // Subscribe to real-time updates
    const unsubscribe = todosRef.onSnapshot((snapshot) => {
      setTodos(snapshot)
    })

    // Cleanup on unmount
    return () => unsubscribe()
  }, [api])

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

Pattern 5: File Upload

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

function ImageUpload() {
  const api = useApi()
  const [uploading, setUploading] = useState(false)
  const [imageUrl, setImageUrl] = useState(null)

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

    setUploading(true)

    try {
      // 1. Create file metadata
      const fileMetadata = await api.file.post({
        fileTp: 'BLOB',
        contentType: file.type
      })

      // 2. Upload binary data
      const formData = new FormData()
      formData.append('file', file)
      formData.append('fileId', fileMetadata.fileId)

      await api.file.upload.post(formData)

      // 3. Get the file URL
      setImageUrl(`/file/${fileMetadata.fileId}`)
    } catch (error) {
      alert('Upload failed: ' + error.message)
    } finally {
      setUploading(false)
    }
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFileChange} />
      {uploading && <div>Uploading...</div>}
      {imageUrl && <img src={imageUrl} alt="Uploaded" />}
    </div>
  )
}

Pattern 6: Dark Mode

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

function ThemeProvider({ children }) {
  const auth = useAuth()

  useEffect(() => {
    // Apply dark mode class to body
    if (auth.settings?.darkMode) {
      document.body.classList.add('dark')
    } else {
      document.body.classList.remove('dark')
    }
  }, [auth.settings?.darkMode])

  return <>{children}</>
}

Complete Example App

Here’s a complete example of a simple post viewer:

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

function App() {
  return (
    <CloudilloProvider appName="posts-viewer">
      <PostsApp />
    </CloudilloProvider>
  )
}

function PostsApp() {
  const auth = useAuth()

  if (!auth.idTag) {
    return <div>Loading...</div>
  }

  return (
    <div className="app">
      <Header />
      <PostsList />
    </div>
  )
}

function Header() {
  const auth = useAuth()

  return (
    <header>
      <h1>Posts</h1>
      <div className="user-info">
        {auth.profilePic && (
          <img src={auth.profilePic} alt={auth.name} />
        )}
        <span>{auth.name}</span>
      </div>
    </header>
  )
}

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

  useEffect(() => {
    api.action.get({ type: 'POST', status: 'A', _limit: 50 })
      .then(data => setPosts(data.data || []))
      .catch(error => console.error('Failed to load posts:', error))
      .finally(() => setLoading(false))
  }, [api])

  if (loading) return <div>Loading posts...</div>

  return (
    <div className="posts-list">
      {posts.map(post => (
        <Post key={post.actionId} post={post} />
      ))}
    </div>
  )
}

function Post({ post }) {
  return (
    <article className="post">
      <div className="post-header">
        {post.issuer?.profilePic && (
          <img src={post.issuer.profilePic} alt={post.issuer.name} />
        )}
        <div>
          <strong>{post.issuer?.name}</strong>
          <span>{post.issuer?.idTag}</span>
        </div>
      </div>
      <div className="post-content">
        <p>{post.content?.text}</p>
      </div>
      <div className="post-stats">
        {post.stat?.reactions > 0 && (
          <span>{post.stat.reactions} reactions</span>
        )}
        {post.stat?.comments > 0 && (
          <span>{post.stat.comments} comments</span>
        )}
      </div>
    </article>
  )
}

export default App

TypeScript Support

All hooks and components are fully typed:

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

function MyComponent() {
  const auth: AuthState = useAuth()

  // TypeScript knows the shape of auth
  auth.idTag // string | undefined
  auth.tnId // number
  auth.roles // string[] | undefined
}

Testing

Mocking CloudilloProvider

import { render } from '@testing-library/react'
import { CloudilloProvider } from '@cloudillo/react'

const mockAuth = {
  tnId: 123,
  idTag: 'test@example.com',
  name: 'Test User',
  roles: ['user']
}

function TestWrapper({ children }) {
  return (
    <CloudilloProvider appName="test-app">
      {children}
    </CloudilloProvider>
  )
}

test('renders user info', () => {
  render(<MyComponent />, { wrapper: TestWrapper })
  // Test your component
})

See Also