State Management Patterns
This guide documents our established patterns for state management, using real examples from our codebase.
Quick Decision Guide
Section titled “Quick Decision Guide”Use this flowchart to determine the appropriate state management approach:
Key Decision Points:
- Scope: Where is the state needed?
- Component-only → Local signals
- Multiple Astro islands → Module-level signals (hybrid pattern)
- App-wide → nanostores
- Type: What kind of data are you storing?
- Hydration: Does it need SSR + client reactivity?
Common Pitfalls:
- Using app-wide state for island-specific data
- Forgetting the hybrid pattern for Astro islands
- Complex state structures in atoms
- Missing loading/error states for async state
- Inconsistent state update patterns
- Hydration mismatches with module-level signals
State Types
Section titled “State Types”Shared State Across Astro Islands (SolidJS Signals)
Section titled “Shared State Across Astro Islands (SolidJS Signals)”When you need to share reactive state between multiple Astro islands using SolidJS, use the hybrid hydration pattern with module-level signals. This pattern enables:
- ✅ Server-side rendering with correct HTML
- ✅ Proper client-side hydration without mismatches
- ✅ Shared reactive state after hydration
- ✅ Cross-component updates via signals
How-To: Implement Hybrid Hydration Pattern
Section titled “How-To: Implement Hybrid Hydration Pattern”1. Create a shared state file
Use module-level signals in a co-located _state.ts file:
import { createSignal, createMemo } from "solid-js";
export interface SaveItem { id: string; sourceType: string; // ... other fields}
// Module-level signals (shared across all components)export const [saves, setSaves] = createSignal<SaveItem[]>([]);export const [activeFilter, setActiveFilter] = createSignal<string | null>( null);
// Track initialization to prevent duplicate setslet initialized = false;
export function initializeSaves(initialSaves: SaveItem[]) { if (!initialized) { setSaves(initialSaves); initialized = true; }}
// Computed valuesexport const savesCount = createMemo(() => saves().length);
export const filteredSaves = createMemo(() => { const filter = activeFilter(); const allSaves = saves(); if (!filter) return allSaves; return allSaves.filter( save => save.sourceType.toLowerCase() === filter.toLowerCase() );});
// Shared actionsexport async function deleteSave(saveId: string): Promise<void> { const allSaves = saves(); const previousSaves = allSaves;
// Optimistic update setSaves(allSaves.filter(save => save.id !== saveId));
try { const response = await fetch(`/api/saves/${saveId}`, { method: "DELETE", });
if (!response.ok) throw new Error("Failed to delete"); } catch (error) { // Revert on error setSaves(previousSaves); console.error("deleteSave error:", error); throw error; }}2. Create components with hybrid hydration
Each component starts with local props during SSR/hydration, then switches to shared signals:
import { type Component, For, createSignal, onMount } from "solid-js";import { filteredSaves, initializeSaves, deleteSave, type SaveItem } from "#pages/profile/saves/_state";
interface GridProps { saves: SaveItem[];}
export const Grid: Component<GridProps> = (props) => { // Start with local state for SSR/hydration const [useSharedState, setUseSharedState] = createSignal(false);
// Once hydrated, switch to shared state onMount(() => { initializeSaves(props.saves); setUseSharedState(true); });
// Use local props during SSR/hydration, then switch to shared filtered signal const items = () => useSharedState() ? filteredSaves() : props.saves;
return ( <ul> <For each={items()}> {(save) => ( <li> <button onClick={() => deleteSave(save.id)}> Delete </button> </li> )} </For> </ul> );};import { type Component, createSignal, onMount } from "solid-js";import { saves, activeFilter, setActiveFilter, initializeSaves } from "#pages/profile/saves/_state";
export const Filters: Component<{ saves: SaveItem[] }> = (props) => { const [useSharedState, setUseSharedState] = createSignal(false);
onMount(() => { initializeSaves(props.saves); setUseSharedState(true); });
const currentSaves = () => useSharedState() ? saves() : props.saves; const isActive = (value: string | null) => useSharedState() ? activeFilter() === value : false;
return ( <div> <button onClick={() => setActiveFilter(null)} class={isActive(null) ? "active" : ""} > All ({currentSaves().length}) </button> </div> );};3. Pass server data as props in Astro
---import { Filters } from "./_components/Filters";import { Grid } from "./_components/Grid";
const serverSaves = await fetchSaves();---
<div> <Filters client:load saves={serverSaves} /> <Grid client:load saves={serverSaves} /></div>Explanation: Why This Pattern Works
Section titled “Explanation: Why This Pattern Works”The Problem
Module-level signals exist outside of SolidJS’s component tree. When using client:load:
- Server: Components render with signals at initial state (empty
[]) - Client: Solid tries to hydrate with same empty state
- After hydration: Signals need to be initialized with actual data
- Issue: If you initialize in component body, you get hydration mismatch (server HTML empty vs client HTML full)
The Solution
The hybrid pattern solves this by:
- During SSR: Component uses
props.saves→ generates correct HTML - During hydration: Component still uses
props.saves→ matches server HTML ✅ - After
onMount: Component switches to shared signals → reactive updates work ✅
The useSharedState signal acts as a “phase switch” that coordinates the transition from prop-based rendering to signal-based reactivity.
Key Principles
- Module-level signals are shared across all component instances
onMountonly runs on the client, never during SSR- Props are stable during SSR and hydration
- The
initializedflag prevents duplicate initialization - Each component can safely call
initializeSaves()- only the first call sets data
Reference: Pattern Variations
Section titled “Reference: Pattern Variations”Single component with count display
export const SaveCount: Component<{ initialCount: number; saves: SaveItem[] }> = (props) => { const [useSharedState, setUseSharedState] = createSignal(false);
onMount(() => { initializeSaves(props.saves); setUseSharedState(true); });
const count = () => useSharedState() ? savesCount() : props.initialCount;
return <span>{count()} items</span>;};Actions without return values
// In state fileexport function updateFilter(filter: string | null) { setActiveFilter(filter);}
// In component<button onClick={() => updateFilter("article")}> Articles</button>Actions with optimistic updates and error handling
export async function toggleSave(id: string): Promise<void> { const current = saves(); const item = current.find(s => s.id === id);
if (!item) return;
// Optimistic update setSaves(current.map(s => (s.id === id ? { ...s, saved: !s.saved } : s)));
try { await fetch(`/api/saves/${id}`, { method: "POST" }); } catch (error) { // Revert setSaves(current); throw error; }}🤔 When to Use This Pattern
- ✅ Multiple Astro islands need to share reactive state
- ✅ State updates should reflect across all islands
- ✅ You want SSR + hydration benefits
- ✅ State is managed centrally (filters, lists, counts)
❌ When NOT to Use This Pattern
- Component-local state (use regular signals)
- Static data that doesn’t change
- Simple one-way data flow (use props)
Component-Level State
Section titled “Component-Level State”We follow specific patterns for managing state in our components. For naming conventions, see our Naming Conventions guide.
SolidJS Signals
Section titled “SolidJS Signals”For component-local state, we use SolidJS’s createSignal:
import { createSignal, createMemo } from "solid-js";
export function LatestArticles(props: LatestArticlesProps) { const [activeTag, setActiveTag] = createSignal(props.defaultTag); const [loading, setLoading] = createSignal(false); const [filteredArticles, setFilteredArticles] = createSignal(props.articles);
// Derived state using createMemo const visibleArticles = createMemo(() => filteredArticles().filter(article => article.tags.includes(activeTag()) ) );
const handleTagChange = async (tag: string) => { setLoading(true); setActiveTag(tag);
try { const articles = await fetchArticlesByTag(tag); setFilteredArticles(articles); } catch (err) { console.error("Failed to fetch articles", { error: err.message, component: "LatestArticles" }); } finally { setLoading(false); } };
return ( <div> <TagSelector active={activeTag()} onChange={handleTagChange} /> <Show when={!loading()} fallback={<LoadingSpinner />} > <ArticleList articles={visibleArticles()} /> </Show> </div> );}🤔 Why This Pattern?
- Fine-grained reactivity
- Automatic dependency tracking
- Efficient updates
- Clear state ownership
Complex Component State
Section titled “Complex Component State”For more complex state, use multiple signals with clear responsibilities:
import { createSignal, createEffect, onCleanup, onMount } from "solid-js";
export function FullScreenGallery(props: GalleryProps) { // UI State const [hasScroll, setHasScroll] = createSignal(false); const [galleryRef, setGalleryRef] = createSignal<HTMLUListElement>(); const [activeChild, setActiveChild] = createSignal(props.currentImageIndex);
// Scroll Position State const [scrollPosition, setScrollPosition] = createSignal({ left: 0, behavior: "smooth" as ScrollBehavior, });
// Side Effects createEffect(() => { const gallery = galleryRef(); if (!gallery) return;
const updateScroll = () => { setHasScroll(gallery.scrollWidth > gallery.clientWidth); };
updateScroll(); window.addEventListener("resize", updateScroll); onCleanup(() => window.removeEventListener("resize", updateScroll)); });
return ( <div> <ul ref={setGalleryRef}> {/* Gallery items */} </ul> <Show when={hasScroll()}> <NavigationControls onNext={() => setActiveChild(prev => prev + 1)} onPrev={() => setActiveChild(prev => prev - 1)} /> </Show> </div> );}🤔 Why This Pattern?
- Separated concerns
- Clear state updates
- Lifecycle management
- Cleanup handling
Form State Management
Section titled “Form State Management”For forms, combine signals with validation and error states:
import { createSignal } from "solid-js";
export function NewsletterForm(props: NewsletterFormProps) { const [email, setEmail] = createSignal(""); const [success, setSuccess] = createSignal(false); const [error, setError] = createSignal(""); const [hasFocused, setHasFocused] = createSignal(false);
const handleSubmit = async (e: Event) => { e.preventDefault();
try { await submitNewsletter({ email: email(), source: props.source });
setSuccess(true); setError(""); } catch (err) { setError(err.message); setSuccess(false); } };
return ( <form onSubmit={handleSubmit}> <Show when={error()}> <p mode="critical" class="text-primary">{error()}</p> </Show> <Show when={success()}> <p mode="success" class="text-primary">Successfully subscribed!</p> </Show> {/* Form fields */} </form> );}🤔 Why This Pattern?
- Clear form state
- Error handling
- Success feedback
- Focus tracking
Best Practices
Section titled “Best Practices”-
Signal Creation
- Create signals at component initialization
- Use descriptive names (e.g.,
isLoadingnotflag) - Initialize with sensible defaults
- Group related signals
-
State Updates
- Update state immutably
- Batch related updates
- Handle errors appropriately
- Clean up side effects
-
Derived State
- Use
createMemofor computed values - Keep computations pure
- Minimize recomputation
- Clear dependencies
- Use
-
Component Organization
- Co-locate state with usage
- Split complex state logic
- Handle cleanup in
onCleanup - Document state shape
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”❌ Don’t: Mutate signal values directly
// Bad: Direct mutationconst items = createSignal([]);items()[0] = newItem; // Don't do this!✅ Do: Update immutably
// Good: Immutable updatesconst [items, setItems] = createSignal([]);setItems(prev => [...prev, newItem]);❌ Don’t: Create signals in loops or conditions
// Bad: Conditional signal creationif (condition) { const [state, setState] = createSignal(0);}✅ Do: Create signals at component initialization
// Good: Consistent signal creationconst [state, setState] = createSignal(condition ? 0 : null);❌ Don’t: Use signals for static values
// Bad: Unnecessary signalconst [config, setConfig] = createSignal({ apiUrl: "https://api.example.com",});✅ Do: Use regular constants
// Good: Static valuesconst config = { apiUrl: "https://api.example.com",};Decision Checklist
Section titled “Decision Checklist”When implementing state management, ask:
-
State Location
- Is this state truly needed?
- Should it be component-local?
- Does it need to be shared?
- What’s the state lifetime?
-
State Structure
- Is the state minimal?
- Are updates efficient?
- Is derived state used?
- Is cleanup needed?
-
State Updates
- Are updates immutable?
- Is error handling in place?
- Are side effects managed?
- Is state initialized properly?
-
Performance
- Are computations optimized?
- Is reactivity granular?
- Are updates batched?
- Is cleanup implemented?