0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · درس

الحضور والمؤشرات وحالة التعاون المباشر

زامن حضور المستخدمين المتعددين والحالة المشتركة باستخدام القنوات وعمليات الدمج المحلية المتفائلة.

الحضور والمؤشرات وحالة التعاون المباشر درس مجاني في Next.js 15 Fullstack (App Router + Server Actions) على CoddyKit. هذا هو الدرس 4 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في Next.js 15 Fullstack (App Router + Server Actions)، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة Next.js 15 Fullstack (App Router + Server Actions) 4 دروس في المجموع.

بعض أجزاء هذا الدرس لم تُترجم بعد وتظهر باللغة الإنجليزية.

What Is Presence in Collaborative Apps?

Presence is the real-time awareness of who else is in the same space as you — and what they are doing. Think of Google Docs showing avatars at the top, or Figma rendering other users' cursors on the canvas.

  • Presence state: which users are currently connected to a channel
  • Cursor state: where each user's mouse or caret is positioned
  • Awareness metadata: name, avatar, active selection, scroll position

In Next.js 15 with the App Router, you build real-time collaboration on top of a channel abstraction — typically provided by Supabase Realtime, Ably, Pusher, or a custom WebSocket server. The principles are the same across providers.

The key design goal is low-latency local feedback: your own cursor must move instantly, and remote cursors should follow with minimal delay.

Setting Up a Supabase Realtime Channel

Supabase Realtime channels support Presence natively. A channel is a named pub/sub room where clients can track each other's state. You create a channel once per collaborative session and call channel.subscribe() to join it.

Install the client and create a typed channel in a client component:

'use client'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

export function useCollabChannel(roomId: string) {
  // Each room gets its own channel scoped by ID
  const channel = supabase.channel(`room:${roomId}`, {
    config: {
      presence: { key: crypto.randomUUID() }, // unique key per tab
    },
  })

  return channel
}

Tracking Presence with channel.track()

Once subscribed, each client calls channel.track(payload) to broadcast its own presence state to every other subscriber in the channel. Supabase merges all presence payloads into a shared map keyed by the presence key you provided.

  • channel.track() — upserts your own state
  • channel.presenceState() — returns the current snapshot of all users
  • The presence event fires whenever someone joins, leaves, or updates

Below is a React hook that tracks the current user and listens for changes:

'use client'
import { useEffect, useState } from 'react'
import { RealtimeChannel } from '@supabase/supabase-js'

type UserPresence = {
  userId: string
  name: string
  color: string
  online_at: string
}

export function usePresence(
  channel: RealtimeChannel,
  me: Omit<UserPresence, 'online_at'>
) {
  const [others, setOthers] = useState<UserPresence[]>([])

  useEffect(() => {
    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState<UserPresence>()
        const allUsers = Object.values(state).flat()
        // Exclude own entry by userId
        setOthers(allUsers.filter((u) => u.userId !== me.userId))
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({ ...me, online_at: new Date().toISOString() })
        }
      })

    return () => { channel.unsubscribe() }
  }, [channel, me.userId])

  return others
}

Rendering Online Avatars from Presence State

With the presence list in React state, rendering an avatar stack is straightforward. Assign each user a deterministic color so their avatar and cursor always match. A good strategy is to derive the color from the userId at login time and include it in the presence payload.

Here is a simple avatar bar component that consumes the hook from the previous scene:

'use client'
import { usePresence } from './usePresence'
import { RealtimeChannel } from '@supabase/supabase-js'

type Props = {
  channel: RealtimeChannel
  me: { userId: string; name: string; color: string }
}

export function AvatarBar({ channel, me }: Props) {
  const others = usePresence(channel, me)

  return (
    <div style={{ display: 'flex', gap: 4 }}>
      {/* Always show yourself first */}
      <Avatar user={me} label="You" />
      {others.map((user) => (
        <Avatar key={user.userId} user={user} />
      ))}
    </div>
  )
}

function Avatar({
  user,
  label,
}: {
  user: { name: string; color: string }
  label?: string
}) {
  return (
    <div
      title={label ?? user.name}
      style={{
        width: 32,
        height: 32,
        borderRadius: '50%',
        background: user.color,
        display: 'grid',
        placeItems: 'center',
        color: '#fff',
        fontSize: 12,
        fontWeight: 700,
      }}
    >
      {user.name[0].toUpperCase()}
    </div>
  )
}

Broadcasting Cursor Positions

Cursor position is too high-frequency for presence — calling channel.track() on every mouse move would flood the presence system. Instead, use broadcast, a fire-and-forget message type designed for rapid ephemeral events with no persistence guarantee.

  • channel.send({ type: 'broadcast', event: 'cursor', payload }) — emits to all subscribers
  • channel.on('broadcast', { event: 'cursor' }, handler) — receives from others
  • Broadcast does not echo back to the sender

Throttle mouse events with requestAnimationFrame or a time gate before sending:

'use client'
import { useEffect, useRef } from 'react'
import { RealtimeChannel } from '@supabase/supabase-js'

export function useCursorBroadcast(
  channel: RealtimeChannel,
  userId: string
) {
  const lastSent = useRef(0)

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      const now = Date.now()
      // Throttle: send at most every 50 ms (~20 fps)
      if (now - lastSent.current < 50) return
      lastSent.current = now

      channel.send({
        type: 'broadcast',
        event: 'cursor',
        payload: { userId, x: e.clientX, y: e.clientY },
      })
    }

    window.addEventListener('mousemove', handleMouseMove)
    return () => window.removeEventListener('mousemove', handleMouseMove)
  }, [channel, userId])
}

Receiving and Rendering Remote Cursors

On the receiving side, maintain a Map of cursor positions keyed by userId. Each broadcast message upserts that user's latest coordinates. React renders a floating div for each remote cursor, positioned absolutely over the canvas.

Combine this with the presence list so you have the user's name and color alongside the coordinates:

'use client'
import { useEffect, useState } from 'react'
import { RealtimeChannel } from '@supabase/supabase-js'

type CursorPos = { userId: string; x: number; y: number }

export function useRemoteCursors(channel: RealtimeChannel) {
  const [cursors, setCursors] = useState<Map<string, CursorPos>>(
    new Map()
  )

  useEffect(() => {
    channel.on(
      'broadcast',
      { event: 'cursor' },
      ({ payload }: { payload: CursorPos }) => {
        setCursors((prev) => {
          const next = new Map(prev)
          next.set(payload.userId, payload)
          return next
        })
      }
    )
  }, [channel])

  return cursors
}

// In your canvas component:
// const cursors = useRemoteCursors(channel)
// {Array.from(cursors.values()).map(({ userId, x, y }) => (
//   <div key={userId} style={{ position: 'fixed', left: x, top: y,
//     pointerEvents: 'none', transform: 'translate(-4px,-4px)' }}>
//     ▶
//   </div>
// ))}

Optimistic Local Merges for Shared State

When multiple users edit shared data (a document, a whiteboard, a form), you must reconcile concurrent changes. The naive approach — round-trip to the server before updating local state — causes visible lag and conflicts.

Optimistic local merge means you apply your own change immediately to local state, broadcast it, and let the server or peers confirm later. The pattern follows three steps:

  • Apply locally first — update your own React state instantly (zero latency)
  • Broadcast the delta — send only the change (not the full document) over the channel
  • Reconcile on receive — when you receive a remote delta, merge it into your local state using a deterministic strategy

A simple conflict rule for text fields: last-write-wins by timestamp. For structured data, a field-level merge is safer.

Broadcasting and Merging State Deltas

Below is a hook that manages a shared Record (e.g., sticky-note positions on a board). Each user can move any note; changes are applied optimistically and broadcast as field-level deltas so concurrent edits to different fields never overwrite each other.

'use client'
import { useCallback, useEffect, useState } from 'react'
import { RealtimeChannel } from '@supabase/supabase-js'

type NoteMap = Record<string, { x: number; y: number; text: string }>
type Delta = { noteId: string; patch: Partial<{ x: number; y: number; text: string }> }

export function useSharedBoard(
  channel: RealtimeChannel,
  initial: NoteMap
) {
  const [board, setBoard] = useState<NoteMap>(initial)

  // Apply an incoming delta to local state
  const applyDelta = useCallback((delta: Delta) => {
    setBoard((prev) => ({
      ...prev,
      [delta.noteId]: { ...prev[delta.noteId], ...delta.patch },
    }))
  }, [])

  // Listen for remote deltas
  useEffect(() => {
    channel.on(
      'broadcast',
      { event: 'board_delta' },
      ({ payload }: { payload: Delta }) => applyDelta(payload)
    )
  }, [channel, applyDelta])

  // Expose a mutate function that applies locally AND broadcasts
  const mutate = useCallback(
    (noteId: string, patch: Delta['patch']) => {
      applyDelta({ noteId, patch }) // optimistic
      channel.send({
        type: 'broadcast',
        event: 'board_delta',
        payload: { noteId, patch },
      })
    },
    [channel, applyDelta]
  )

  return { board, mutate }
}

Persisting State via a Server Action

Broadcast deltas are ephemeral — a user who joins mid-session sees nothing. You need a persistent source of truth. The pattern is:

  • On mount, fetch initial state from the server (Server Component or fetch)
  • On each local mutation, also call a Server Action to persist the delta to the database
  • New joiners load the latest persisted state, then subscribe for live deltas

The Server Action debounces writes to avoid a database call on every keystroke:

// app/actions/board.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function persistNotePatch(
  roomId: string,
  noteId: string,
  patch: { x?: number; y?: number; text?: string }
) {
  const supabase = await createClient()

  const { error } = await supabase
    .from('notes')
    .update(patch)
    .eq('id', noteId)
    .eq('room_id', roomId)

  if (error) throw new Error(error.message)

  // Invalidate the page so a fresh SSR render has up-to-date data
  revalidatePath(`/rooms/${roomId}`)
}

// On the client, debounce the call:
// const save = useDebouncedCallback(
//   (noteId, patch) => persistNotePatch(roomId, noteId, patch),
//   800
// )
// Call save(noteId, patch) inside mutate() after the broadcast.

Handling User Disconnection and Cleanup

When a user navigates away or closes the tab, Supabase Realtime automatically removes their presence entry — no explicit leave message is needed. However, cursor positions stored in your local React state are not automatically cleaned up.

Listen for the leave presence event to remove stale cursors:

'use client'
import { useEffect, useState } from 'react'
import { RealtimeChannel } from '@supabase/supabase-js'

type CursorPos = { userId: string; x: number; y: number }

export function useCleanupOnLeave(
  channel: RealtimeChannel,
  setCursors: React.Dispatch<React.SetStateAction<Map<string, CursorPos>>>
) {
  useEffect(() => {
    channel.on(
      'presence',
      { event: 'leave' },
      ({ leftPresences }: { leftPresences: Array<{ userId: string }> }) => {
        setCursors((prev) => {
          const next = new Map(prev)
          for (const p of leftPresences) {
            next.delete(p.userId)
          }
          return next
        })
      }
    )
  }, [channel, setCursors])
}

Pure TypeScript: Merging Presence Maps

Understanding the merge semantics of presence and delta state is easier with a pure function you can reason about in isolation. This standalone snippet demonstrates a last-write-wins field merge for a presence-keyed state map — the same logic your hooks use internally.

This code is fully self-contained and runnable with the TypeScript compiler:

type PresenceEntry = { userId: string; name: string; updatedAt: number }
type PresenceMap = Record<string, PresenceEntry>

function mergePresence(
  current: PresenceMap,
  incoming: PresenceEntry
): PresenceMap {
  const existing = current[incoming.userId]
  // Keep whichever entry has the higher timestamp
  if (existing && existing.updatedAt >= incoming.updatedAt) {
    return current
  }
  return { ...current, [incoming.userId]: incoming }
}

function removeUser(map: PresenceMap, userId: string): PresenceMap {
  const { [userId]: _, ...rest } = map
  return rest
}

// --- demo ---
let state: PresenceMap = {}

state = mergePresence(state, { userId: 'u1', name: 'Alice', updatedAt: 1000 })
state = mergePresence(state, { userId: 'u2', name: 'Bob',   updatedAt: 1001 })
// Stale update for Alice — should be ignored
state = mergePresence(state, { userId: 'u1', name: 'Alice-old', updatedAt: 900 })

console.log('Presence map:', JSON.stringify(state, null, 2))
// Alice's name should still be 'Alice', not 'Alice-old'

state = removeUser(state, 'u2')
console.log('After Bob leaves:', Object.keys(state))
// Output: ['u1']

Knowledge Check: Cursor Position Updates

In a collaborative canvas app built with Next.js 15 and Supabase Realtime, you need to sync live cursor positions between users. Which channel mechanism is the most appropriate choice, and why?

Lesson Recap: Presence, Cursors, and Live State

In this lesson you built the foundational layer of a real-time collaborative experience in Next.js 15:

  • Presence via channel.track() — broadcasts slow-changing metadata (name, color, online status) and maintains a merged map of all connected users; clients auto-removed on disconnect
  • Cursor broadcast — high-frequency mouse positions sent with channel.send({ type: 'broadcast' }), throttled to ~20 fps to avoid flooding; never use presence for this
  • Optimistic local merges — apply your own change to React state instantly, broadcast the delta, and reconcile remote deltas with a field-level merge to avoid overwriting concurrent edits
  • Persistence via Server Actions — debounced writes to the database give new joiners a valid initial state; combine with revalidatePath for SSR freshness
  • Cleanup on leave — listen for the presence leave event to remove stale cursor overlays from local state

The architecture separates concerns cleanly: presence for identity, broadcast for ephemeral position, Server Actions + database for durable truth. This combination scales to dozens of simultaneous collaborators with low perceived latency.

الأسئلة الشائعة

هل درس «الحضور والمؤشرات وحالة التعاون المباشر» مجاني؟

نعم — نص درس «الحضور والمؤشرات وحالة التعاون المباشر» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة Next.js 15 Fullstack (App Router + Server Actions)، انتقل إلى CoddyKit PRO. تتضمن دورة Next.js 15 Fullstack (App Router + Server Actions) 4 دروس في المجموع.

ماذا ستتعلم في «الحضور والمؤشرات وحالة التعاون المباشر»؟

زامن حضور المستخدمين المتعددين والحالة المشتركة باستخدام القنوات وعمليات الدمج المحلية المتفائلة. تتمرن على Next.js 15 Fullstack (App Router + Server Actions) مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.

هل أحتاج إلى خبرة سابقة لأبدأ Next.js 15 Fullstack (App Router + Server Actions)؟

لا تُشترط خبرة سابقة. Next.js 15 Fullstack (App Router + Server Actions) على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 4 من أصل 4.

كم من الوقت يستغرق درس «الحضور والمؤشرات وحالة التعاون المباشر»؟

معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.

هل يمكنني كتابة وتشغيل أكواد في درس Next.js 15 Fullstack (App Router + Server Actions) هذا؟

نعم. كل درس في Next.js 15 Fullstack (App Router + Server Actions) يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.

جميع الدروس في هذه الدورة

  1. الأحداث المرسلة من الخادم من معالجات المسارات
  2. دمج خدمات WebSocket في عالم بلا خوادم
  3. بث استجابات الذكاء الاصطناعي رمزًا تلو الآخر
  4. الحضور والمؤشرات وحالة التعاون المباشر
← العودة إلى Next.js 15 Fullstack (App Router + Server Actions)