Next.js Client-Side Rendering (CSR)

Overview

Client-Side Rendering (CSR) renders page content in the browser using JavaScript. Next.js supports CSR through client components, making it suitable for scenarios that require interactivity and real-time updates.

What is CSR?

How CSR Works

  1. The server returns minimal HTML
  2. The browser downloads JavaScript
  3. JavaScript executes on the client
  4. Page content is rendered dynamically
  5. User interactions are handled

When to Use CSR

  • Highly interactive applications
  • Real-time data updates
  • Content that requires user authentication
  • Chat and messaging applications
  • Complex user interfaces

Client Components

The 'use client' Directive

// components/Counter.tsx
'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        增加
      </button>
    </div>
  )
}

Using React Hooks

'use client'

import { useState, useEffect } from 'react'

export default function UserProfile() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        setUser(data)
        setLoading(false)
      })
  }, [])
  
  if (loading) return <div>加载中...</div>
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

Data Fetching

Using useEffect

'use client'

import { useState, useEffect } from 'react'

export default function Posts() {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    fetch('https://api.example.com/posts')
      .then(res => {
        if (!res.ok) throw new Error('获取失败')
        return res.json()
      })
      .then(data => {
        setPosts(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])
  
  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

Using SWR

'use client'

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then(res => res.json())

export default function Posts() {
  const { data, error, isLoading } = useSWR(
    'https://api.example.com/posts',
    fetcher
  )
  
  if (isLoading) return <div>加载中...</div>
  if (error) return <div>加载失败</div>
  
  return (
    <div>
      {data.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

Using React Query

'use client'

import { useQuery } from '@tanstack/react-query'

export default function Posts() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await fetch('https://api.example.com/posts')
      return res.json()
    }
  })
  
  if (isLoading) return <div>加载中...</div>
  if (error) return <div>错误: {error.message}</div>
  
  return (
    <div>
      {data.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

Event Handling

Basic Events

'use client'

export default function Form() {
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    console.log('表单提交')
  }
  
  const handleClick = () => {
    console.log('按钮点击')
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" />
      <button type="submit">提交</button>
      <button type="button" onClick={handleClick}>
        取消
      </button>
    </form>
  )
}

Form Handling

'use client'

import { useState } from 'react'

export default function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  })
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    })
  }
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    
    const res = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData)
    })
    
    if (res.ok) {
      alert('发送成功')
      setFormData({ name: '', email: '', message: '' })
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="姓名"
      />
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="邮箱"
      />
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
        placeholder="消息"
      />
      <button type="submit">发送</button>
    </form>
  )
}

State Management

Local State

'use client'

import { useState } from 'react'

export default function TodoList() {
  const [todos, setTodos] = useState([])
  const [input, setInput] = useState('')
  
  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, { id: Date.now(), text: input }])
      setInput('')
    }
  }
  
  const removeTodo = (id: number) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }
  
  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
      />
      <button onClick={addTodo}>添加</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => removeTodo(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Context API

'use client'

import { createContext, useContext, useState } from 'react'

const ThemeContext = createContext(null)

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  return useContext(ThemeContext)
}

// 使用
export default function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      切换到 {theme === 'light' ? '暗色' : '亮色'} 模式
    </button>
  )
}

Practical Examples

'use client'

import { useState, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'

export default function Search() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)
  
  const debouncedQuery = useDebounce(query, 500)
  
  useEffect(() => {
    if (debouncedQuery) {
      setLoading(true)
      fetch(`/api/search?q=${debouncedQuery}`)
        .then(res => res.json())
        .then(data => {
          setResults(data)
          setLoading(false)
        })
    } else {
      setResults([])
    }
  }, [debouncedQuery])
  
  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      {loading && <div>搜索中...</div>}
      <ul>
        {results.map(result => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  )
}

Infinite Scroll

'use client'

import { useState, useEffect, useRef } from 'react'

export default function InfiniteScroll() {
  const [items, setItems] = useState([])
  const [page, setPage] = useState(1)
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const observerRef = useRef(null)
  
  useEffect(() => {
    loadMore()
  }, [page])
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          setPage(prev => prev + 1)
        }
      },
      { threshold: 1.0 }
    )
    
    if (observerRef.current) {
      observer.observe(observerRef.current)
    }
    
    return () => observer.disconnect()
  }, [hasMore, loading])
  
  const loadMore = async () => {
    setLoading(true)
    const res = await fetch(`/api/items?page=${page}`)
    const data = await res.json()
    
    setItems(prev => [...prev, ...data.items])
    setHasMore(data.hasMore)
    setLoading(false)
  }
  
  return (
    <div>
      {items.map(item => (
        <div key={item.id}>{item.title}</div>
      ))}
      {loading && <div>加载中...</div>}
      <div ref={observerRef} />
    </div>
  )
}

Real-Time Chat

'use client'

import { useState, useEffect, useRef } from 'react'

export default function Chat() {
  const [messages, setMessages] = useState([])
  const [input, setInput] = useState('')
  const wsRef = useRef(null)
  
  useEffect(() => {
    // 连接 WebSocket
    wsRef.current = new WebSocket('ws://localhost:3001')
    
    wsRef.current.onmessage = (event) => {
      const message = JSON.parse(event.data)
      setMessages(prev => [...prev, message])
    }
    
    return () => {
      wsRef.current?.close()
    }
  }, [])
  
  const sendMessage = () => {
    if (input.trim() && wsRef.current) {
      wsRef.current.send(JSON.stringify({
        text: input,
        timestamp: Date.now()
      }))
      setInput('')
    }
  }
  
  return (
    <div>
      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i}>{msg.text}</div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
      />
      <button onClick={sendMessage}>发送</button>
    </div>
  )
}

Best Practices

1. Mix Server and Client Components

// app/page.tsx (服务端组件)
import ClientComponent from './ClientComponent'

async function getData() {
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

export default async function Page() {
  const data = await getData()
  
  return (
    <div>
      <h1>{data.title}</h1>
      <ClientComponent initialData={data} />
    </div>
  )
}

// ClientComponent.tsx (客户端组件)
'use client'

export default function ClientComponent({ initialData }) {
  const [data, setData] = useState(initialData)
  
  return <div>{/* 交互式内容 */}</div>
}

2. Optimize Performance

'use client'

import { memo, useMemo, useCallback } from 'react'

const ExpensiveComponent = memo(({ data }) => {
  const processedData = useMemo(() => {
    return data.map(item => /* 复杂计算 */)
  }, [data])
  
  const handleClick = useCallback(() => {
    // 处理点击
  }, [])
  
  return <div onClick={handleClick}>{/* ... */}</div>
})

3. Error Boundaries

'use client'

import { Component } from 'react'

class ErrorBoundary extends Component {
  state = { hasError: false }
  
  static getDerivedStateFromError() {
    return { hasError: true }
  }
  
  render() {
    if (this.state.hasError) {
      return <div>出错了</div>
    }
    return this.props.children
  }
}

Previous Chapter: Incremental Static Regeneration (ISR) | Next Chapter: Streaming