Skip to main content

Client RPC

Client-side utilities for calling server functions with streaming, abort signals, and custom fetch options.

useServerFn

React hook for calling server functions with loading states.
import { useServerFn } from '@tanstack/react-start'
import { getPosts } from './server-functions'

function PostsList() {
  const getPostsFn = useServerFn(getPosts)
  
  return (
    <button onClick={() => getPostsFn()}>
      Refresh Posts
    </button>
  )
}

Custom Fetch Options

Pass custom fetch options when calling server functions.
import { getPosts } from './server-functions'

// With custom headers
const posts = await getPosts({
  headers: {
    'Authorization': 'Bearer token',
    'X-Custom-Header': 'value'
  }
})

// With abort signal
const controller = new AbortController()

const posts = await getPosts({
  signal: controller.signal
})

// Cancel the request
controller.abort()

Streaming Responses

Server functions support streaming responses for large datasets or real-time updates.

Server-Side Streaming

import { createServerFn } from '@tanstack/start'

const streamLogs = createServerFn({ method: 'GET' })
  .handler(async function* ({ data }) => {
    // Generator function for streaming
    const logs = await db.logs.findMany({
      where: { sessionId: data.sessionId },
      orderBy: { timestamp: 'asc' }
    })
    
    for (const log of logs) {
      yield log
      await new Promise(resolve => setTimeout(resolve, 100))
    }
  })

Client-Side Consumption

import { streamLogs } from './server-functions'

function LogViewer({ sessionId }) {
  const [logs, setLogs] = useState([])
  
  useEffect(() => {
    const stream = streamLogs({ data: { sessionId } })
    
    ;(async () => {
      for await (const log of stream) {
        setLogs(prev => [...prev, log])
      }
    })()
  }, [sessionId])
  
  return (
    <div>
      {logs.map(log => (
        <div key={log.id}>{log.message}</div>
      ))}
    </div>
  )
}

Error Handling

Handle errors from server functions on the client.
import { isNotFound, isRedirect } from '@tanstack/start'
import { getPost } from './server-functions'

function PostDetails({ postId }) {
  const [error, setError] = useState(null)
  
  const loadPost = async () => {
    try {
      const post = await getPost({ data: { id: postId } })
      return post
    } catch (err) {
      if (isNotFound(err)) {
        setError('Post not found')
      } else if (isRedirect(err)) {
        // Handle redirect
        console.log('Redirecting to:', err.to)
      } else {
        setError('An error occurred')
      }
    }
  }
  
  return (
    <div>
      {error && <div className="error">{error}</div>}
      <button onClick={loadPost}>Load Post</button>
    </div>
  )
}

Request Deduplication

Multiple concurrent calls to the same server function are automatically deduplicated.
import { getPost } from './server-functions'

function Component() {
  useEffect(() => {
    // These three calls will result in only one network request
    getPost({ data: { id: '1' } })
    getPost({ data: { id: '1' } })
    getPost({ data: { id: '1' } })
  }, [])
}

Custom Fetch

Provide a custom fetch implementation.
import { getPosts } from './server-functions'

const customFetch = async (url, options) => {
  console.log('Fetching:', url)
  return fetch(url, options)
}

const posts = await getPosts({
  fetch: customFetch
})

Serialization

Server functions automatically serialize and deserialize complex data types.

Supported Types

  • Primitives (string, number, boolean, null, undefined)
  • Objects and Arrays
  • Date objects
  • RegExp objects
  • Map and Set
  • Error objects
  • Custom serializable types

Example

const getEvent = createServerFn({ method: 'GET' })
  .handler(async () => {
    return {
      id: '1',
      title: 'Event',
      date: new Date('2024-01-01'),
      attendees: new Set(['user1', 'user2']),
      metadata: new Map([['key', 'value']])
    }
  })

// Client automatically receives properly typed objects
const event = await getEvent()
event.date instanceof Date // true
event.attendees instanceof Set // true
event.metadata instanceof Map // true

Server Function Context

Access request context on the client.
import { getServerContext } from '@tanstack/start'

function MyComponent() {
  // Only works during SSR or in server components
  const context = getServerContext()
  
  if (context) {
    console.log('Request URL:', context.request.url)
  }
}

Examples

Form Submission

import { useServerFn } from '@tanstack/react-start'
import { createPost } from './server-functions'

function CreatePostForm() {
  const createPostFn = useServerFn(createPost)
  const [isSubmitting, setIsSubmitting] = useState(false)
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    setIsSubmitting(true)
    
    try {
      const formData = new FormData(e.target)
      const post = await createPostFn({
        data: {
          title: formData.get('title'),
          content: formData.get('content')
        }
      })
      
      console.log('Created:', post)
      e.target.reset()
    } catch (error) {
      console.error('Failed to create post:', error)
    } finally {
      setIsSubmitting(false)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

Infinite Scroll

import { useInfiniteQuery } from '@tanstack/react-query'
import { getPosts } from './server-functions'

function InfinitePostsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam = 0 }) => {
      return await getPosts({
        data: { 
          offset: pageParam,
          limit: 20 
        }
      })
    },
    getNextPageParam: (lastPage, pages) => {
      return lastPage.length === 20 ? pages.length * 20 : undefined
    }
  })
  
  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.map(post => (
            <div key={post.id}>{post.title}</div>
          ))}
        </div>
      ))}
      
      {hasNextPage && (
        <button 
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  )
}

Real-time Updates

import { streamMessages } from './server-functions'

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])
  const [isConnected, setIsConnected] = useState(false)
  
  useEffect(() => {
    const controller = new AbortController()
    
    ;(async () => {
      try {
        setIsConnected(true)
        const stream = streamMessages({
          data: { roomId },
          signal: controller.signal
        })
        
        for await (const message of stream) {
          setMessages(prev => [...prev, message])
        }
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Stream error:', error)
        }
      } finally {
        setIsConnected(false)
      }
    })()
    
    return () => controller.abort()
  }, [roomId])
  
  return (
    <div>
      <div className={isConnected ? 'connected' : 'disconnected'}>
        {isConnected ? 'Connected' : 'Disconnected'}
      </div>
      
      <div className="messages">
        {messages.map(msg => (
          <div key={msg.id}>{msg.text}</div>
        ))}
      </div>
    </div>
  )
}

Optimistic Updates

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createPost } from './server-functions'

function OptimisticPostCreator() {
  const queryClient = useQueryClient()
  
  const mutation = useMutation({
    mutationFn: createPost,
    onMutate: async (newPost) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['posts'] })
      
      // Snapshot previous value
      const previousPosts = queryClient.getQueryData(['posts'])
      
      // Optimistically update
      queryClient.setQueryData(['posts'], (old) => [
        { ...newPost, id: 'temp', createdAt: new Date() },
        ...old
      ])
      
      return { previousPosts }
    },
    onError: (err, newPost, context) => {
      // Rollback on error
      queryClient.setQueryData(['posts'], context.previousPosts)
    },
    onSettled: () => {
      // Refetch after success or error
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    }
  })
  
  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const formData = new FormData(e.target)
      mutation.mutate({
        data: {
          title: formData.get('title'),
          content: formData.get('content')
        }
      })
    }}>
      <input name="title" />
      <textarea name="content" />
      <button type="submit">Create</button>
    </form>
  )
}

Build docs developers (and LLMs) love