Error Boundaries All

Gracefully handle errors in your components with hierarchical error boundaries, fallback UI, and recovery mechanisms.

Key Concept: Error boundaries catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of crashing the entire application.

How Error Boundaries Work

When an error occurs in a component, WildflowerJS follows this propagation pattern:

Error Propagation
  1. Error occurs in component (init, action, computed, destroy)
  2. Component's onError() handler is called (if defined)
  3. If handler returns true, error is handled - propagation stops
  4. If handler returns false or is missing, error bubbles to parent
  5. Process repeats up the component hierarchy
  6. Global handlers receive unhandled errors
Error Sources
  • init() - Component initialization errors
  • actions - User interaction errors
  • computed - Computed property evaluation errors
  • destroy() - Cleanup errors
  • onUpdate() - Update lifecycle errors

Basic Error Handling

Add an onError() method to catch errors in your component:

wildflower.component('user-profile', {
    state: {
        user: null,
        errorMessage: ''
    },

    init() {
        // This might throw if user data is invalid
        this.loadUser()
    },

    loadUser() {
        // Simulating an error
        throw new Error('Failed to load user data')
    },

    // Error boundary handler
    onError(error, context) {
        console.error('Error in user-profile:', error.message)

        // Update state to show error UI
        this.errorMessage = error.message

        // Return true to mark error as handled
        // Return false to let it propagate to parent
        return true
    }
})
<div data-component="user-profile">
    <div data-show="errorMessage">
        <div class="alert alert-danger">
            Error: <span data-bind="errorMessage"></span>
        </div>
    </div>
    <div data-show="!errorMessage">
        <!-- Normal content -->
    </div>
</div>

Error Context

The onError handler receives the error and context information:

wildflower.component('context-aware', {
    state: {},

    onError(error, context) {
        // error - The Error object with message and stack trace
        console.log('Error message:', error.message)
        console.log('Stack trace:', error.stack)

        // context - Additional information about where the error occurred
        console.log('Component:', context?.component)     // The component instance
        console.log('Lifecycle:', context?.lifecycle)     // 'init', 'action', 'computed', 'onUpdate', ...
        console.log('Action:', context?.action)           // Action name if error was in action
        console.log('Method:', context?.methodName)       // Method name that threw
        console.log('Computed:', context?.computedName)   // Computed name when lifecycle is 'computed'

        // A handler that claims a specific cause should check these first and
        // return false for anything it does not recognise.
        return true
    }
})

Stores, Plugins, Queries and Pools

A store or a plugin can declare onError too, with the same arguments. It receives errors thrown by the entity's own init() (lifecycle: 'init'), computed properties, tick(), watchers, and methods (context.methodName).

A store or plugin method is usually called by your own code, so what happens to its error depends on whether the entity has an onError. With one, the handler receives the error and the call returns undefined, as a component action does. Without one, the error is thrown to the code that called the method, where a try/catch can handle it.

A pool's onAdd, onRemove and onClear hooks, and an entity method run from a data-action, report to the onError of the component, store or plugin that owns the pool (lifecycle: 'pool-hook' for the hooks). The pool operation still completes, so a throwing onClear no longer leaves a pool half cleared.

A query has no onError of its own. When a function from throws or rejects, the message goes into the query's error field and is also printed to the console, once the retries are used up. A params function that throws is printed as well, and the request is sent without the declared params.

An error that no onError handles is printed to the console in every build, including production.

Error Propagation

Errors bubble up through the component hierarchy until handled:

<!-- Parent catches child errors -->
<div data-component="error-boundary">
    <div data-component="risky-child">
        <!-- If this throws and doesn't handle, parent catches -->
    </div>
</div>
// Parent component acts as error boundary
wildflower.component('error-boundary', {
    state: {
        hasError: false,
        childError: ''
    },

    onError(error, context) {
        // Catches errors from child components
        this.hasError = true
        this.childError = error.message
        return true  // Stop propagation
    }
})

// Child component with potential errors
wildflower.component('risky-child', {
    state: {},

    init() {
        throw new Error('Something went wrong!')
    }
    // No onError - error propagates to parent
})

Controlling Propagation

Return false from onError to let the error continue propagating:

wildflower.component('logging-boundary', {
    state: {},

    onError(error, context) {
        // Log the error but let parent handle it
        console.warn('Error logged:', error.message)

        // Return false to propagate to parent
        return false
    }
})

Global Error Handlers

Register global handlers to catch errors that bubble up past all component boundaries:

// Register a global error handler
wildflower.onError((error, component) => {
    console.error('Unhandled error:', error.message)
    console.error('In component:', component?.element?.dataset.component)

    // Send to error tracking service
    errorTrackingService.capture(error, {
        component: component?.element?.dataset.component,
        state: component?.state
    })
})

// You can register multiple handlers
wildflower.onError((error) => {
    // Show user notification
    showNotification('An error occurred. Please try again.')
})

// Remove a specific handler
const handler = (error) => console.log(error)
wildflower.onError(handler)
wildflower.offError(handler)  // Removes the handler

Fallback UI

Use data-error-fallback to automatically show fallback content when errors occur:

<div data-component="data-viewer" data-error-fallback=".error-fallback">
    <!-- Normal content -->
    <div class="normal-content">
        <span data-bind="data"></span>
    </div>

    <!-- Fallback shown on error -->
    <div class="error-fallback" style="display: none;">
        <div class="alert alert-danger">
            <h4>Unable to load data</h4>
            <p>Please try refreshing the page.</p>
            <button data-action="retry">Try Again</button>
        </div>
    </div>
</div>

Template-based Fallback

Reference a template element for reusable fallback UI:

<!-- Reusable error template -->
<template id="error-template">
    <div class="error-state">
        <span class="error-icon">!</span>
        <h3>Something went wrong</h3>
        <p>We're working on fixing this.</p>
    </div>
</template>

<!-- Use template as fallback -->
<div data-component="widget" data-error-fallback="#error-template">
    <!-- Widget content -->
</div>

Error Recovery

Components can recover from errors and retry operations:

wildflower.component('recoverable-loader', {
    state: {
        status: 'loading',
        data: null,
        retryCount: 0
    },

    async init() {
        await this.loadData()
    },

    async loadData() {
        this.status = 'loading'

        try {
            // Simulated API call that might fail
            const response = await fetch('/api/data')
            if (!response.ok) throw new Error('Failed to fetch')

            this.data = await response.json()
            this.status = 'success'
        } catch (error) {
            throw error  // Let onError handle it
        }
    },

    retry() {
        this.retryCount++
        this.loadData()
    },

    onError(error) {
        this.status = 'error'

        // Auto-retry up to 3 times
        if (this.retryCount < 3) {
            setTimeout(() => this.retry(), 1000 * this.retryCount)
        }

        return true
    }
})
<div data-component="recoverable-loader">
    <div data-show="status === 'loading'">
        <div class="spinner">Loading...</div>
    </div>

    <div data-show="status === 'success'">
        <div data-bind="data.name"></div>
    </div>

    <div data-show="status === 'error'">
        <p>Failed to load data.</p>
        <button data-action="retry">
            Retry (<span data-bind="retryCount"></span> attempts)
        </button>
    </div>
</div>

Sibling Isolation

Errors in one component don't affect its siblings:

<div data-component="dashboard">
    <!-- If widget-a throws, widget-b still works -->
    <div data-component="widget-a"></div>
    <div data-component="widget-b"></div>
    <div data-component="widget-c"></div>
</div>
// Widget A has an error
wildflower.component('widget-a', {
    init() {
        throw new Error('Widget A failed')
    },
    onError(error) {
        console.log('Widget A error handled')
        return true
    }
})

// Widget B initializes normally despite Widget A's error
wildflower.component('widget-b', {
    state: { status: 'ok' },
    init() {
        console.log('Widget B initialized successfully')
    }
})

Best Practices

Do
  • Place error boundaries at strategic points (pages, features)
  • Log errors for debugging (console, tracking service)
  • Provide clear fallback UI with recovery options
  • Handle known error cases explicitly
  • Use global handlers for error tracking
  • Return true when you've handled the error
Don't
  • Wrap every component in an error boundary
  • Silently swallow errors without logging
  • Show technical error messages to users
  • Forget to handle async errors properly
  • Let errors crash the entire application
  • Throw errors in onError handlers

Error Boundary Patterns

Page-Level Boundary

Wrap entire pages to prevent full app crashes:

wildflower.component('page-boundary', {
    state: {
        pageError: null
    },

    onError(error) {
        this.pageError = {
            message: 'This page encountered an error.',
            details: error.message,
            timestamp: new Date().toISOString()
        }
        return true
    },

    refreshPage() {
        window.location.reload()
    }
})

Feature-Level Boundary

Isolate features so one broken feature doesn't break others:

wildflower.component('feature-boundary', {
    state: {
        featureName: '',
        isDisabled: false
    },

    onError(error) {
        console.error(`Feature "${this.featureName}" disabled:`, error)
        this.isDisabled = true
        return true
    }
})

Key Features

  • Declarative fallback UI - Use data-error-fallback attribute, no conditional rendering needed
  • Propagation control - Return true or false from onError() to control bubbling
  • Global handlers - wildflower.onError() catches errors application-wide
  • Rich error context - Access error details, component info, and lifecycle phase
  • Error recovery - Built-in resetError() method to restore normal operation
  • Automatic cleanup - Failed component state is properly managed