SaaS Tutorial: Islands & State
The dashboard currently renders server-side and is completely static. In this chapter, you'll build interactive islands that use @ereo/state signals to create a live-updating dashboard — statistics cards that animate, an activity feed, and a task board where status changes happen instantly without full page reloads.
Understanding Islands in TaskFlow
Islands are self-contained interactive components that hydrate on the client while the rest of the page stays as server-rendered HTML. This gives us the best of both worlds:
- Fast initial load: The full page HTML is server-rendered
- Selective interactivity: Only the widgets that need JavaScript get hydrated
- Shared state: Signals let islands communicate without prop-drilling
Here's what we'll build:
┌──────────────────────────────────────────────┐
│ Dashboard (server-rendered) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ StatsCards │ │ StatsCards │ ← Island │
│ │ (island) │ │ (island) │ │
│ └─────────────┘ └─────────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ TaskBoard │ │ ActivityFeed │ │
│ │ (island) │ │ (island) │ │
│ │ ← drag status │ │ ← live updates │ │
│ └──────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────┘Shared State with Signals
Create a shared state module that all dashboard islands can import. This is where signals shine — multiple islands can subscribe to the same signal and stay in sync.
// app/lib/dashboard-state.ts
import { signal, computed } from '@ereo/state'
export interface TaskSummary {
id: string
title: string
status: 'todo' | 'in_progress' | 'done'
priority: 'low' | 'medium' | 'high' | 'urgent'
assigneeName?: string
}
export interface DashboardData {
stats: {
projects: number
members: number
tasks: { todo: number; in_progress: number; done: number; total: number }
}
tasks: TaskSummary[]
recentActivity: Array<{
id: string
action: string
targetType: string
userName: string
createdAt: string
}>
}
// Signals that islands share
export const dashboardData = signal<DashboardData | null>(null)
// Derived computations
export const tasksByStatus = computed(
(data) => {
if (!data) return { todo: [], in_progress: [], done: [] }
return {
todo: data.tasks.filter((t) => t.status === 'todo'),
in_progress: data.tasks.filter((t) => t.status === 'in_progress'),
done: data.tasks.filter((t) => t.status === 'done'),
}
},
[dashboardData]
)
export const completionRate = computed(
(data) => {
if (!data || data.stats.tasks.total === 0) return 0
return Math.round((data.stats.tasks.done / data.stats.tasks.total) * 100)
},
[dashboardData]
)Dashboard Stats Island
Build the statistics cards. They read from the shared signal and animate when values change:
// app/components/DashboardStats.tsx
'use client'
import { useSignal } from '@ereo/state'
import { createIsland } from '@ereo/client'
import { dashboardData, completionRate } from '~/lib/dashboard-state'
import type { DashboardData } from '~/lib/dashboard-state'
interface DashboardStatsProps {
initialData: DashboardData
}
function DashboardStatsComponent({ initialData }: DashboardStatsProps) {
// Initialize the shared signal if this is the first island to render
if (!dashboardData.get()) {
dashboardData.set(initialData)
}
const data = useSignal(dashboardData)
const completion = useSignal(completionRate)
if (!data) return null
const { stats } = data
const cards = [
{ label: 'Projects', value: stats.projects, color: 'bg-blue-500' },
{ label: 'Total Tasks', value: stats.tasks.total, color: 'bg-purple-500' },
{ label: 'In Progress', value: stats.tasks.in_progress, color: 'bg-yellow-500' },
{ label: 'Completed', value: `${completion}%`, color: 'bg-green-500' },
]
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card) => (
<div key={card.label} className="bg-white rounded-lg border border-gray-200 p-5">
<div className="flex items-center gap-3">
<div className={`w-2 h-8 rounded-full ${card.color}`} />
<div>
<p className="text-2xl font-bold">{card.value}</p>
<p className="text-sm text-gray-500">{card.label}</p>
</div>
</div>
</div>
))}
</div>
)
}
export default createIsland(DashboardStatsComponent, 'DashboardStats')Activity Feed Island
The activity feed subscribes to the same signal and renders recent events. In the next chapter, we'll make this update in real time via RPC subscriptions:
// app/components/ActivityFeed.tsx
'use client'
import { useSignal } from '@ereo/state'
import { createIsland } from '@ereo/client'
import { dashboardData } from '~/lib/dashboard-state'
import type { DashboardData } from '~/lib/dashboard-state'
interface ActivityFeedProps {
initialData: DashboardData
}
const ACTION_VERBS: Record<string, string> = {
created: 'created',
updated: 'updated',
deleted: 'deleted',
completed: 'completed',
assigned: 'assigned',
}
function ActivityFeedComponent({ initialData }: ActivityFeedProps) {
if (!dashboardData.get()) {
dashboardData.set(initialData)
}
const data = useSignal(dashboardData)
if (!data) return null
return (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-5 py-4 border-b border-gray-100">
<h3 className="font-semibold">Recent Activity</h3>
</div>
<div className="divide-y divide-gray-100">
{data.recentActivity.length === 0 ? (
<p className="px-5 py-4 text-sm text-gray-400">No activity yet.</p>
) : (
data.recentActivity.slice(0, 10).map((event) => (
<div key={event.id} className="px-5 py-3 flex items-start gap-3">
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-xs font-medium shrink-0 mt-0.5">
{event.userName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm">
<span className="font-medium">{event.userName}</span>{' '}
{ACTION_VERBS[event.action] || event.action}{' '}
a {event.targetType}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{formatRelativeTime(event.createdAt)}
</p>
</div>
</div>
))
)}
</div>
</div>
)
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDay = Math.floor(diffHr / 24)
return `${diffDay}d ago`
}
export default createIsland(ActivityFeedComponent, 'ActivityFeed')Task Board Island
The task board renders tasks in columns and allows changing status via buttons. When a status changes, the signal updates and all islands re-render:
// app/components/TaskBoard.tsx
'use client'
import { useSignal, batch } from '@ereo/state'
import { createIsland } from '@ereo/client'
import { dashboardData, tasksByStatus } from '~/lib/dashboard-state'
import type { DashboardData, TaskSummary } from '~/lib/dashboard-state'
interface TaskBoardProps {
projectId: string
initialData: DashboardData
}
const STATUS_LABELS: Record<string, string> = {
todo: 'To Do',
in_progress: 'In Progress',
done: 'Done',
}
const PRIORITY_COLORS: Record<string, string> = {
low: 'bg-gray-100 text-gray-700',
medium: 'bg-blue-100 text-blue-700',
high: 'bg-orange-100 text-orange-700',
urgent: 'bg-red-100 text-red-700',
}
function TaskBoardComponent({ projectId, initialData }: TaskBoardProps) {
if (!dashboardData.get()) {
dashboardData.set(initialData)
}
const grouped = useSignal(tasksByStatus)
async function moveTask(taskId: string, newStatus: TaskSummary['status']) {
// Optimistic update: change the signal immediately
const current = dashboardData.get()
if (!current) return
batch(() => {
const updatedTasks = current.tasks.map((t) =>
t.id === taskId ? { ...t, status: newStatus } : t
)
const statusDelta = { todo: 0, in_progress: 0, done: 0 }
const oldTask = current.tasks.find((t) => t.id === taskId)
if (oldTask) {
statusDelta[oldTask.status] = -1
statusDelta[newStatus] = 1
}
dashboardData.set({
...current,
tasks: updatedTasks,
stats: {
...current.stats,
tasks: {
...current.stats.tasks,
todo: current.stats.tasks.todo + statusDelta.todo,
in_progress: current.stats.tasks.in_progress + statusDelta.in_progress,
done: current.stats.tasks.done + statusDelta.done,
},
},
})
})
// Persist to server
await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{(['todo', 'in_progress', 'done'] as const).map((status) => (
<div key={status}>
<div className="flex items-center gap-2 mb-3">
<h2 className="font-semibold text-sm text-gray-500 uppercase tracking-wide">
{STATUS_LABELS[status]}
</h2>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{grouped[status].length}
</span>
</div>
<div className="space-y-3">
{grouped[status].map((task) => (
<TaskCard key={task.id} task={task} onMove={moveTask} />
))}
{grouped[status].length === 0 && (
<p className="text-sm text-gray-400 text-center py-8 border border-dashed border-gray-200 rounded-lg">
No tasks
</p>
)}
</div>
</div>
))}
</div>
)
}
function TaskCard({ task, onMove }: { task: TaskSummary; onMove: (id: string, status: TaskSummary['status']) => void }) {
const nextStatuses: Record<string, TaskSummary['status'][]> = {
todo: ['in_progress'],
in_progress: ['todo', 'done'],
done: ['in_progress'],
}
const statusArrow: Record<string, string> = {
todo: 'Start',
in_progress: 'Complete',
done: 'Reopen',
}
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-medium mb-2 text-sm">{task.title}</h3>
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${PRIORITY_COLORS[task.priority]}`}>
{task.priority}
</span>
{task.assigneeName && (
<span className="text-xs text-gray-500">{task.assigneeName}</span>
)}
</div>
<div className="flex gap-1">
{nextStatuses[task.status]?.map((next) => (
<button
key={next}
onClick={() => onMove(task.id, next)}
className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 transition-colors"
>
{next === 'done' ? 'Complete' : next === 'in_progress' ? 'Start' : 'Move to To Do'}
</button>
))}
</div>
</div>
)
}
export default createIsland(TaskBoardComponent, 'TaskBoard')Dashboard Route
Wire the islands into the dashboard page:
// app/routes/dashboard/index.tsx
import { createLoader } from '@ereo/data'
import { Link } from '@ereo/client'
import { getDashboardStats, getTasksForProject, getRecentActivity, getProjectsForTeam } from '~/lib/queries'
import { getUser } from '@ereo/auth'
import { db } from '~/lib/db'
import { teamMembers, tasks, projects as projectsTable } from '~/lib/schema'
import { eq } from 'drizzle-orm'
import type { RouteComponentProps } from '@ereo/core'
import DashboardStats from '~/components/DashboardStats'
import TaskBoard from '~/components/TaskBoard'
import ActivityFeed from '~/components/ActivityFeed'
export const loader = createLoader(async ({ context }) => {
const user = getUser(context)!
const membership = db.select().from(teamMembers).where(eq(teamMembers.userId, user.id)).get()!
const teamId = membership.teamId
const stats = getDashboardStats(teamId)
const projects = getProjectsForTeam(teamId)
const recentActivity = getRecentActivity(teamId, 10)
// Load all tasks across the team's projects
const allTasks = db
.select({
id: tasks.id,
title: tasks.title,
status: tasks.status,
priority: tasks.priority,
})
.from(tasks)
.innerJoin(projectsTable, eq(projectsTable.id, tasks.projectId))
.where(eq(projectsTable.teamId, teamId))
.all()
const dashboardData = {
stats,
tasks: allTasks,
recentActivity: recentActivity.map((a) => ({
...a,
createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : String(a.createdAt),
})),
}
return { dashboardData, projects }
})
export default function Dashboard({ loaderData }: RouteComponentProps) {
const { dashboardData } = loaderData
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
</div>
{/* Stats cards island */}
<DashboardStats client:load initialData={dashboardData} />
{/* Task board + Activity feed */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<h2 className="font-semibold mb-4">Task Board</h2>
<TaskBoard client:load projectId="all" initialData={dashboardData} />
</div>
<div>
<ActivityFeed client:load initialData={dashboardData} />
</div>
</div>
</div>
)
}How Signals Connect the Islands
The key insight is that all three islands share the same dashboardData signal:
dashboardData (signal)
│
├── DashboardStats reads stats → re-renders cards
├── TaskBoard reads tasks → re-renders columns
└── ActivityFeed reads activity → re-renders feed
When TaskBoard calls moveTask():
1. dashboardData.set(newData) ← batch update
2. DashboardStats re-renders ← completion % changes
3. TaskBoard re-renders ← task moves column
4. ActivityFeed stays same ← no activity change yetBecause computed signals derive from dashboardData, updating the parent signal cascades to all consumers. The batch() call ensures all signal updates happen atomically, preventing intermediate renders.
Try It Out
- Navigate to
/dashboard - The stats cards should show your project and task counts
- Click "Start" on a to-do task — it moves to "In Progress" instantly
- The stats card updates: "In Progress" count increases
- Click "Complete" — the task moves to "Done" and the completion percentage updates
What We've Done
- Created a shared state module with
@ereo/statesignals - Built three interactive islands: DashboardStats, TaskBoard, ActivityFeed
- Used
computedsignals for derived data (completion rate, tasks by status) - Implemented optimistic updates with
batch()for instant UI feedback - Used
createIsland()to wrap components for automatic SSR rendering and client hydration - All islands share state — changing a task status in TaskBoard updates DashboardStats automatically
Next Step
The dashboard is interactive, but changes only appear for the current user. In the next chapter, we'll add RPC procedures and WebSocket subscriptions so that task changes broadcast to all connected team members in real time.
← Previous: Forms | Continue to Chapter 6: RPC & Real-time →