Thinking about Astro Islands
When working with Astro’s island architecture, each React component is isolated from others. This creates a challenge when you need to share state between components, especially when:
- Multiple components need access to the same data
- Components need to stay in sync with each other
- You want to avoid redundant API calls
- You need to maintain a consistent UI state across the page
Traditional React patterns like lifting state up or using a global context provider don’t work well in this environment because each island has its own React tree.
The main insight here is around how JavaScript modules are executed in the browser. When Astro builds your site:
- Each island becomes its own bundle with its dependencies
- If multiple islands import the same module (let’s call it
shared.js
), that module will be:- Included in the dependency graph for each island
- But executed only once when the first island loads it
- Subsequent islands will use the already initialized instance
This execution order guarantee is the key that makes sharing state possible. If we have:
Island A → imports → shared.js (with QueryClient)
Island B → imports → shared.js (with QueryClient)
When the browser loads these islands, shared.js
will be executed before either island’s code runs. This means the singleton QueryClient will be initialized once and then available for all islands to access.
Why React Query is a Perfect Fit
Taking step back let’s see why React Query is a great choice for this problem.
React Query offers several advantages that make it ideal:
- Cache-based architecture: React Query’s core is a cache that can be shared
- Automatic deduplication: Identical queries automatically share data
- Stale-while-revalidate pattern: Components always show something while refreshing
- Configurable staleness: Control when data needs refreshing
- Built-in prefetching: Load data before it’s needed
Implementation
Let’s implement a complete example with a todo application that has multiple components needing access to the same data:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ComponentType, ReactNode } from "react";
// Create a singleton QueryClient that will be shared across all components
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
},
},
});
/**
* Higher-order component that wraps a component with a QueryClientProvider
* using a shared QueryClient instance.
*
* This is useful for Astro islands where you need isolated contexts
* but want to share the same query client.
*
* @param Component - The component to wrap
* @returns A new component wrapped with QueryClientProvider
*/
export function withQuery<P extends object>(
Component: ComponentType<P>,
): ComponentType<P> {
// The display name for the wrapped component
const displayName = Component.displayName || Component.name || "Component";
// The wrapped component
const WithQuery = (props: P) => {
const { children, ...componentProps } = props as P & {
children?: ReactNode;
};
return (
<QueryClientProvider client={queryClient}>
<Component {...(componentProps as P)}>{children}</Component>
</QueryClientProvider>
);
};
// Set the display name for better debugging
WithQuery.displayName = `withQuery(${displayName})`;
return WithQuery;
}
// Export the queryClient in case it needs to be accessed directly
export { queryClient };
This pattern is similar to what I used in my contact form tutorial, where we needed to share state between form components while maintaining Astro’s performance benefits.
Using the Pattern in Practice
Now let’s use this withQuery hook to share fetched API data between our islands. In our first TodoList file we want to display our Todo items.
// src/lib/todo-api.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
// src/components/TodoList.tsx
import { useQuery } from "@tanstack/react-query";
import { fetchTodos, Todo } from "../lib/todo-api";
import { withQuery } from "../lib/with-query";
function TodoList() {
const { data, isLoading, error } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
if (isLoading) return <div>Loading todos...</div>;
if (error) return <div>Error loading todos</div>;
return (
<ul className="todo-list">
{data?.map((todo) => (
<li key={todo.id} className={todo.completed ? "completed" : ""}>
{todo.title}
</li>
))}
</ul>
);
}
export default withQuery(TodoList);
In our TodoStats we’re using that same query to derive some quick stats.
// src/components/TodoStats.tsx
import { useQuery } from "@tanstack/react-query";
import { fetchTodos } from "../lib/todo-api";
import { withQuery } from "../lib/with-query";
function TodoStats() {
const { data, isLoading } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
if (isLoading) return <div>Loading stats...</div>;
const totalTodos = data?.length || 0;
const completedTodos = data?.filter((todo) => todo.completed).length || 0;
const percentComplete =
totalTodos > 0 ? Math.round((completedTodos / totalTodos) * 100) : 0;
return (
<div className="todo-stats">
<h3>Todo Statistics</h3>
<p>Total todos: {totalTodos}</p>
<p>Completed: {completedTodos}</p>
<p>Completion rate: {percentComplete}%</p>
</div>
);
}
export default withQuery(TodoStats);
Finally we need a form to actually create our Todos. Here we’re invalidating the singleton queryClient cache when we finish our mutation.
Because our components share this single queryClient instance this will cause them to update with new data.
// src/components/TodoForm.tsx
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { updateTodo } from "../lib/todo-api";
import { withQuery } from "../lib/with-query";
import { queryClient } from "../lib/query-client";
function TodoForm() {
const [todoId, setTodoId] = useState("");
const [title, setTitle] = useState("");
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
// Invalidate the todos query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["todos"] });
// Reset form
setTodoId("");
setTitle("");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!todoId || !title) return;
mutation.mutate({
id: parseInt(todoId),
title,
completed: false,
});
};
return (
<form onSubmit={handleSubmit} className="todo-form">
<h3>Update Todo</h3>
<div>
<label htmlFor="todoId">Todo ID:</label>
<input
id="todoId"
type="number"
value={todoId}
onChange={(e) => setTodoId(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="title">New Title:</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Updating..." : "Update Todo"}
</button>
{mutation.isError && (
<p className="error">Error: {mutation.error.message}</p>
)}
{mutation.isSuccess && (
<p className="success">Todo updated successfully!</p>
)}
</form>
);
}
export default withQuery(TodoForm);
Using in Astro Templates
Now we can use these components in our Astro templates. Note the client:load
this tells Astro that it should ship the javascript bundle to the client.
---
// src/pages/todos.astro
import TodoList from "../components/TodoList";
import TodoStats from "../components/TodoStats";
import TodoForm from "../components/TodoForm";
---
<html lang="en">
<head>
<title>Todo Application</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles/global.css" />
</head>
<body>
<main>
<h1>Todo Application</h1>
<div class="todo-container">
<div class="todo-section">
<h2>My Todos</h2>
<TodoList client:load />
</div>
<div class="stats-section">
<TodoStats client:load />
</div>
</div>
<div class="form-section">
<TodoForm client:load />
</div>
</main>
</body>
</html>
Advanced Usage
Prefetching Data
One of the advantages of this pattern is the ability to prefetch data on the server or during navigation:
// In your Astro component
import { queryClient } from "../lib/query-client";
import { fetchTodos } from "../lib/todo-api";
// Prefetch todos data
await queryClient.prefetchQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
Persisting Cache Between Page Loads
For an even better user experience, you can persist the cache between page loads:
// src/lib/query-client.ts
import { QueryClient, dehydrate, hydrate } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
// Create the client
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
});
// Only run this code in the browser
if (typeof window !== "undefined") {
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
});
persistQueryClient({
queryClient,
persister: localStoragePersister,
});
}
Performance Considerations
This pattern offers several performance benefits:
- Reduced API calls: Multiple components share the same data
- Smaller bundle sizes: No need for a global state management library
- Efficient updates: Only affected components re-render
- Controlled refetching: Configure when data should be considered stale
- Background updates: Users see fresh data without interruption
Beyond Islands: Towards SPA-like Experiences
This pattern brings us closer to building SPA-like experiences with Astro while maintaining its core performance benefits. By sharing state between islands, we can create applications that feel cohesive and responsive like a traditional SPA, but with the performance advantages of Astro’s partial hydration model.
For many applications, this Astro pattern provides the best of both worlds:
- Performance first: Ship minimal JavaScript by default
- Progressive enhancement: Add interactivity only where needed
- Shared state: Maintain a cohesive user experience across islands
- Simplified mental model: No need to manage server/client boundaries as explicitly as in Next.js
As this pattern demonstrates, Astro isn’t just for static sites with sprinkles of interactivity—it’s a viable alternative to frameworks like Next.js for building rich, data-driven applications that prioritize performance without sacrificing user experience.
Conclusion
Sharing state between Astro islands doesn’t have to be complicated. By leveraging React Query’s powerful caching system and a singleton QueryClient, we can achieve seamless state sharing without sacrificing the performance benefits of Astro’s island architecture.
This pattern is:
- Simple: Just a small HOC and a shared client instance
- Flexible: Works with any React Query features
- Efficient: Minimizes API calls and renders
- Maintainable: Follows established React patterns
Next time you’re building an Astro site with multiple interactive components that need to share data, give this pattern a try. It provides a clean solution to a common problem while maintaining the performance benefits that made you choose Astro in the first place.