Skip to content

State Management Patterns

This guide documents our established patterns for state management, using real examples from our codebase.

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

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:

pages/profile/saves/_state.ts
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 sets
let initialized = false;
export function initializeSaves(initialSaves: SaveItem[]) {
if (!initialized) {
setSaves(initialSaves);
initialized = true;
}
}
// Computed values
export 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 actions
export 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:

pages/profile/saves/_components/Grid.tsx
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>
);
};
pages/profile/saves/_components/Filters.tsx
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

pages/profile/saves/index.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>

The Problem

Module-level signals exist outside of SolidJS’s component tree. When using client:load:

  1. Server: Components render with signals at initial state (empty [])
  2. Client: Solid tries to hydrate with same empty state
  3. After hydration: Signals need to be initialized with actual data
  4. 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:

  1. During SSR: Component uses props.saves → generates correct HTML
  2. During hydration: Component still uses props.saves → matches server HTML ✅
  3. 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
  • onMount only runs on the client, never during SSR
  • Props are stable during SSR and hydration
  • The initialized flag prevents duplicate initialization
  • Each component can safely call initializeSaves() - only the first call sets data

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 file
export 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)

We follow specific patterns for managing state in our components. For naming conventions, see our Naming Conventions guide.

For component-local state, we use SolidJS’s createSignal:

components/latestArticles/latestArticles.tsx
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

For more complex state, use multiple signals with clear responsibilities:

components/fullScreenGallery/fullScreenGallery.tsx
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

For forms, combine signals with validation and error states:

components/newsletterForm/newsletterForm.tsx
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
  1. Signal Creation

    • Create signals at component initialization
    • Use descriptive names (e.g., isLoading not flag)
    • Initialize with sensible defaults
    • Group related signals
  2. State Updates

    • Update state immutably
    • Batch related updates
    • Handle errors appropriately
    • Clean up side effects
  3. Derived State

    • Use createMemo for computed values
    • Keep computations pure
    • Minimize recomputation
    • Clear dependencies
  4. Component Organization

    • Co-locate state with usage
    • Split complex state logic
    • Handle cleanup in onCleanup
    • Document state shape

Don’t: Mutate signal values directly

// Bad: Direct mutation
const items = createSignal([]);
items()[0] = newItem; // Don't do this!

Do: Update immutably

// Good: Immutable updates
const [items, setItems] = createSignal([]);
setItems(prev => [...prev, newItem]);

Don’t: Create signals in loops or conditions

// Bad: Conditional signal creation
if (condition) {
const [state, setState] = createSignal(0);
}

Do: Create signals at component initialization

// Good: Consistent signal creation
const [state, setState] = createSignal(condition ? 0 : null);

Don’t: Use signals for static values

// Bad: Unnecessary signal
const [config, setConfig] = createSignal({
apiUrl: "https://api.example.com",
});

Do: Use regular constants

// Good: Static values
const config = {
apiUrl: "https://api.example.com",
};

When implementing state management, ask:

  1. State Location

    • Is this state truly needed?
    • Should it be component-local?
    • Does it need to be shared?
    • What’s the state lifetime?
  2. State Structure

    • Is the state minimal?
    • Are updates efficient?
    • Is derived state used?
    • Is cleanup needed?
  3. State Updates

    • Are updates immutable?
    • Is error handling in place?
    • Are side effects managed?
    • Is state initialized properly?
  4. Performance

    • Are computations optimized?
    • Is reactivity granular?
    • Are updates batched?
    • Is cleanup implemented?