One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

useLoaderState

A hook that provides manual refetch control and loading state for loaders. It extends the functionality of useLoader by adding the ability to manually refetch data and track loading states.

Basic Usage

useLoaderState can be used in two different ways:

With a loader (replaces useLoader)

When you pass a loader function, you get back the data along with refetch and state:

import { useLoaderState } from 'one'
export function loader({ path }) {
const url = new URL(path, 'http://localhost')
const query = url.searchParams.get('q') || ''
return fetch(`/api/search?q=${query}`).then(r => r.json())
}
export default function SearchPage() {
const { data, refetch, state } = useLoaderState(loader)
return (
<div>
<h1>Search Results</h1>
{state === 'loading' ? (
<Spinner />
) : (
<Results data={data} />
)}
<button onClick={refetch} disabled={state === 'loading'}>
Refresh Results
</button>
</div>
)
}

Without a loader (access from anywhere)

When called without arguments, you can access the refetch function and state from any component in the tree:

import { useLoaderState } from 'one'
function RefreshButton() {
const { refetch, state } = useLoaderState()
return (
<button
onClick={refetch}
disabled={state === 'loading'}
>
{state === 'loading' ? 'Refreshing...' : 'Refresh Page'}
</button>
)
}
// This button can be placed anywhere in your component tree
// and will refetch the current route's loader

Return Values

When called with a loader

const { data, refetch, state } = useLoaderState(loader)

When called without a loader

const { refetch, state } = useLoaderState()

How It Works

All useLoaderState hooks on the same route share state through an internal subscription system. This means:

  1. When refetch() is called from any component, the loader cache for that route is cleared
  2. All components subscribed to that route are notified and re-render
  3. The loader is executed again with fresh data
  4. All components receive the updated data simultaneously

This architecture enables powerful patterns like having a refresh button in your header that can refetch data for the current page, without needing to pass props down through your component tree.

Common Use Cases

Pull-to-Refresh

function PullToRefreshWrapper({ children }) {
const { refetch, state } = useLoaderState()
return (
<PullToRefresh onRefresh={refetch} refreshing={state === 'loading'}>
{children}
</PullToRefresh>
)
}

Polling for Live Data

function useLivePolling(intervalMs = 5000) {
const { refetch, state } = useLoaderState()
useEffect(() => {
const interval = setInterval(() => {
// Only refetch if not already loading
if (state === 'idle') {
refetch()
}
}, intervalMs)
return () => clearInterval(interval)
}, [refetch, state, intervalMs])
}
export default function LiveDashboard() {
const { data } = useLoaderState(loader)
useLivePolling(3000) // Poll every 3 seconds
return <Dashboard data={data} />
}

Error Recovery

export default function DataPage() {
const { data, refetch, state } = useLoaderState(loader)
if (data?.error) {
return (
<ErrorBoundary
error={data.error}
onRetry={refetch}
retrying={state === 'loading'}
/>
)
}
return <PageContent data={data} />
}

Form Revalidation

function CommentForm({ postId }) {
const { refetch } = useLoaderState()
const handleSubmit = async (formData) => {
await submitComment(postId, formData)
// Refetch the loader to get updated comments
refetch()
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
</form>
)
}

Comparison with useLoader

FeatureuseLoaderuseLoaderState
Returns loader data✅✅
Manual refetch❌✅
Loading state❌✅
Can be called without loader❌✅
Can be used in child components❌✅

Use useLoader when you only need the data and automatic refetching is sufficient.

Use useLoaderState when you need:

TypeScript

The hook is fully typed based on your loader’s return type:

export function loader() {
return {
users: [] as User[],
total: 0
}
}
// TypeScript knows the shape of data
const { data, refetch, state } = useLoaderState(loader)
// ^? { users: User[], total: number }
// Without a loader, data is not available
const { refetch, state } = useLoaderState()
// ^? { refetch: () => void, state: 'idle' | 'loading' }
// Note: no 'data' property when called without loader

Migration from useLoader

Migrating from useLoader to useLoaderState is straightforward:

// Before
import { useLoader } from 'one'
export default function Page() {
const data = useLoader(loader)
return <div>{data.content}</div>
}
// After
import { useLoaderState } from 'one'
export default function Page() {
const { data, refetch, state } = useLoaderState(loader)
return (
<div>
{state === 'loading' && <LoadingSpinner />}
{data.content}
<button onClick={refetch}>Refresh</button>
</div>
)
}

Working with Different Rendering Modes

useLoaderState works consistently across all rendering modes:

The refetch functionality always executes on the client, ensuring consistent behavior regardless of the initial rendering mode.

Loader Timing History

For debugging and performance monitoring, you can access the timing history of loader executions:

import { getLoaderTimingHistory } from 'one'
function DebugPanel() {
const timings = getLoaderTimingHistory()
return (
<div>
{timings.map((entry) => (
<div key={`${entry.path}:${entry.startTime}`}>
<strong>{entry.path}</strong>: {entry.totalTime}ms
{entry.error && <span> (Error: {entry.error})</span>}
</div>
))}
</div>
)
}

The LoaderTimingEntry type contains:

type LoaderTimingEntry = {
path: string // Route path (e.g., '/users/123')
startTime: number // Timestamp when loader started
moduleLoadTime?: number // Time to load the loader module
executionTime?: number // Time to execute the loader
totalTime?: number // Total time from start to finish
error?: string // Error message if loader failed
source: 'preload' | 'initial' | 'refetch' // What triggered the load
}

This function is useful for:

The history is only populated in development mode and stores the last 50 entries to avoid memory growth.

Edit this page on GitHub.