Client-Side Hydration Guide

Learn how to add interactivity to server-rendered Coherent.js components through client-side hydration.

What is Hydration?

Hydration is the process of attaching event listeners and making server-rendered HTML interactive on the client-side. Coherent.js provides seamless hydration that maintains the pure JavaScript object philosophy.

Basic Hydration

Simple Component Hydration

// shared/components.js (used on both server and client)
export const Counter = ({ initialCount = 0 }) => {
  let count = initialCount;
  
  const updateDisplay = () => {
    const element = document.getElementById('counter-display');
    if (element) element.textContent = count;
  };
  
  const increment = () => {
    count++;
    updateDisplay();
  };
  
  const decrement = () => {
    count--;
    updateDisplay();
  };
  
  return {
    div: {
      className: 'counter',
      children: [
        { h2: { text: 'Counter Example' } },
        { div: {
          className: 'counter-display',
          id: 'counter-display',
          text: count.toString()
        }},
        { div: {
          className: 'counter-controls',
          children: [
            { button: {
              onclick: increment,
              text: '+',
              className: 'btn btn-increment'
            }},
            { button: {
              onclick: decrement,
              text: '-',
              className: 'btn btn-decrement'
            }}
          ]
        }}
      ]
    }
  };
};

Server-Side Rendering

// server.js
import { renderToString } from 'coherent-js';
import { Counter } from './shared/components.js';

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    const component = {
      html: {
        children: [
          { head: {
            children: [
              { title: { text: 'Hydration Example' } },
              { script: { src: '/client.js', defer: true } }
            ]
          }},
          { body: {
            children: [
              Counter({ initialCount: 5 }),
              // Hydration data
              { script: {
                text: `window.__HYDRATION_DATA__ = ${JSON.stringify({
                  counter: { initialCount: 5 }
                })};`
              }}
            ]
          }}
        ]
      }
    };
    
    const html = renderToString(component);
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(`<!DOCTYPE html>${html}`);
  }
});

Client-Side Hydration

// client.js
import { hydrate, hydrateAll } from 'coherent-js';
import { Counter } from './shared/components.js';

// Hydrate when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
  // Get hydration data passed from server
  const hydrationData = window.__HYDRATION_DATA__ || {};
  
  // Find and hydrate counter component
  const counterElement = document.querySelector('.counter');
  if (counterElement) {
    hydrate(counterElement, Counter, hydrationData.counter);
  }
});

Advanced Hydration Patterns

Automatic Hydration

import { autoHydrate, makeHydratable } from 'coherent-js';

// Mark components as hydratable
const HydratableButton = makeHydratable(({ text, onClick }) => ({
  button: {
    className: 'hydratable-btn',
    'data-component': 'HydratableButton',
    onclick: onClick,
    text: text
  }
}));

// Auto-hydrate all marked components
document.addEventListener('DOMContentLoaded', () => {
  autoHydrate({
    HydratableButton: HydratableButton
  });
});

Selective Hydration by Selector

import { hydrateBySelector } from 'coherent-js';

// Hydrate all components with specific class
document.addEventListener('DOMContentLoaded', () => {
  hydrateBySelector('.interactive-component', InteractiveComponent, {
    // Component props
    theme: 'dark',
    enableAnimations: true
  });
});

Batch Hydration

import { hydrateAll } from 'coherent-js';

document.addEventListener('DOMContentLoaded', () => {
  const elements = [
    document.querySelector('.header'),
    document.querySelector('.sidebar'),
    document.querySelector('.main-content')
  ];
  
  const components = [HeaderComponent, SidebarComponent, MainComponent];
  
  const propsArray = [
    { title: 'My Site' },
    { collapsed: false },
    { content: 'Welcome!' }
  ];
  
  hydrateAll(elements, components, propsArray);
});

State Management

Component-Level State

const TodoApp = ({ initialTodos = [] }) => {
  let todos = [...initialTodos];
  let nextId = Math.max(...todos.map(t => t.id || 0)) + 1;
  
  const render = () => {
    const container = document.getElementById('todo-app');
    if (!container) return;
    
    // Re-render the entire component
    container.innerHTML = '';
    const component = createTodoList();
    container.appendChild(renderToDOM(component));
  };
  
  const addTodo = (text) => {
    todos.push({ id: nextId++, text, completed: false });
    render();
  };
  
  const toggleTodo = (id) => {
    todos = todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    );
    render();
  };
  
  const removeTodo = (id) => {
    todos = todos.filter(todo => todo.id !== id);
    render();
  };
  
  const createTodoList = () => ({
    div: {
      className: 'todo-app',
      children: [
        { h2: { text: 'Todo List' } },
        { form: {
          onsubmit: (e) => {
            e.preventDefault();
            const input = e.target.querySelector('input');
            if (input.value.trim()) {
              addTodo(input.value.trim());
              input.value = '';
            }
          },
          children: [
            { input: { 
              type: 'text', 
              placeholder: 'Add new todo...',
              className: 'todo-input'
            }},
            { button: { type: 'submit', text: 'Add' } }
          ]
        }},
        { ul: {
          className: 'todo-list',
          children: todos.map(todo => ({
            li: {
              className: todo.completed ? 'completed' : '',
              children: [
                { input: {
                  type: 'checkbox',
                  checked: todo.completed,
                  onchange: () => toggleTodo(todo.id)
                }},
                { span: { text: todo.text, className: 'todo-text' } },
                { button: {
                  text: 'Delete',
                  className: 'delete-btn',
                  onclick: () => removeTodo(todo.id)
                }}
              ]
            }
          }))
        }}
      ]
    }
  });
  
  return createTodoList();
};

Global State Management

// store.js - Simple global state management
class SimpleStore {
  constructor(initialState = {}) {
    this.state = initialState;
    this.listeners = [];
  }
  
  getState() {
    return this.state;
  }
  
  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.listeners.forEach(listener => listener(this.state));
  }
  
  subscribe(listener) {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }
}

export const store = new SimpleStore({
  user: null,
  theme: 'light',
  notifications: []
});

// components/UserProfile.js
import { store } from '../store.js';

export const UserProfile = () => {
  let currentUser = store.getState().user;
  
  // Subscribe to state changes
  store.subscribe((state) => {
    if (state.user !== currentUser) {
      currentUser = state.user;
      updateUserDisplay();
    }
  });
  
  const updateUserDisplay = () => {
    const element = document.getElementById('user-profile');
    if (element) {
      element.innerHTML = renderUserContent();
    }
  };
  
  const renderUserContent = () => {
    if (!currentUser) {
      return '<p>Please log in</p>';
    }
    return `
      <h3>Welcome, ${currentUser.name}!</h3>
      <p>Email: ${currentUser.email}</p>
    `;
  };
  
  const login = async (credentials) => {
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });
      const user = await response.json();
      store.setState({ user });
    } catch (error) {
      console.error('Login failed:', error);
    }
  };
  
  return {
    div: {
      id: 'user-profile',
      className: 'user-profile',
      html: renderUserContent()
    }
  };
};

Event Handling

Complex Event Patterns

const InteractiveForm = ({ onSubmit }) => {
  const validateField = (field, value) => {
    const errors = {};
    
    if (field === 'email' && !value.includes('@')) {
      errors.email = 'Invalid email address';
    }
    
    if (field === 'password' && value.length < 8) {
      errors.password = 'Password must be at least 8 characters';
    }
    
    return errors;
  };
  
  const handleFieldChange = (field, value) => {
    const errorElement = document.getElementById(`${field}-error`);
    const errors = validateField(field, value);
    
    if (errorElement) {
      errorElement.textContent = errors[field] || '';
      errorElement.style.display = errors[field] ? 'block' : 'none';
    }
  };
  
  const handleSubmit = async (event) => {
    event.preventDefault();
    
    const formData = new FormData(event.target);
    const data = Object.fromEntries(formData.entries());
    
    // Validate all fields
    const errors = {};
    Object.keys(data).forEach(field => {
      const fieldErrors = validateField(field, data[field]);
      Object.assign(errors, fieldErrors);
    });
    
    if (Object.keys(errors).length > 0) {
      // Show validation errors
      Object.keys(errors).forEach(field => {
        const errorElement = document.getElementById(`${field}-error`);
        if (errorElement) {
          errorElement.textContent = errors[field];
          errorElement.style.display = 'block';
        }
      });
      return;
    }
    
    // Submit form
    try {
      await onSubmit(data);
    } catch (error) {
      console.error('Form submission failed:', error);
    }
  };
  
  return {
    form: {
      className: 'interactive-form',
      onsubmit: handleSubmit,
      children: [
        { div: {
          className: 'form-group',
          children: [
            { label: { text: 'Email:', for: 'email' } },
            { input: {
              type: 'email',
              id: 'email',
              name: 'email',
              required: true,
              oninput: (e) => handleFieldChange('email', e.target.value)
            }},
            { div: {
              id: 'email-error',
              className: 'error-message',
              style: 'display: none; color: red;'
            }}
          ]
        }},
        { div: {
          className: 'form-group',
          children: [
            { label: { text: 'Password:', for: 'password' } },
            { input: {
              type: 'password',
              id: 'password',
              name: 'password',
              required: true,
              oninput: (e) => handleFieldChange('password', e.target.value)
            }},
            { div: {
              id: 'password-error',
              className: 'error-message',
              style: 'display: none; color: red;'
            }}
          ]
        }},
        { button: {
          type: 'submit',
          text: 'Submit',
          className: 'submit-btn'
        }}
      ]
    }
  };
};

Custom Event System

// EventBus for component communication
class EventBus {
  constructor() {
    this.events = {};
  }
  
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
  
  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(callback => callback(data));
    }
  }
}

export const eventBus = new EventBus();

// Component using event bus
const ShoppingCart = ({ items = [] }) => {
  let cartItems = [...items];
  
  // Listen for add to cart events
  eventBus.on('addToCart', (product) => {
    cartItems.push(product);
    updateCartDisplay();
  });
  
  // Listen for remove from cart events
  eventBus.on('removeFromCart', (productId) => {
    cartItems = cartItems.filter(item => item.id !== productId);
    updateCartDisplay();
  });
  
  const updateCartDisplay = () => {
    const element = document.getElementById('cart-items');
    if (element) {
      element.innerHTML = renderCartItems();
    }
    
    const countElement = document.getElementById('cart-count');
    if (countElement) {
      countElement.textContent = cartItems.length.toString();
    }
  };
  
  const renderCartItems = () => {
    return cartItems.map(item => `
      <div class="cart-item">
        <span>${item.name}</span>
        <span>${item.price}</span>
        <button onclick="eventBus.emit('removeFromCart', ${item.id})">Remove</button>
      </div>
    `).join('');
  };
  
  return {
    div: {
      className: 'shopping-cart',
      children: [
        { h3: { text: 'Shopping Cart' } },
        { div: {
          className: 'cart-count',
          children: [
            { span: { text: 'Items: ' } },
            { span: { id: 'cart-count', text: cartItems.length.toString() } }
          ]
        }},
        { div: { id: 'cart-items', html: renderCartItems() } }
      ]
    }
  };
};

Performance Optimization

Lazy Hydration

// Intersection Observer for lazy hydration
const createLazyHydrator = (component, props = {}) => {
  return (element) => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          // Element is visible, hydrate it
          hydrate(entry.target, component, props);
          observer.unobserve(entry.target);
        }
      });
    }, {
      rootMargin: '100px' // Start loading 100px before visible
    });
    
    observer.observe(element);
  };
};

// Usage
document.addEventListener('DOMContentLoaded', () => {
  // Hydrate immediately visible components
  const header = document.querySelector('.header');
  if (header) {
    hydrate(header, HeaderComponent);
  }
  
  // Lazy hydrate below-fold components
  const lazyComponents = document.querySelectorAll('.lazy-component');
  lazyComponents.forEach(element => {
    const componentType = element.dataset.component;
    const lazyHydrator = createLazyHydrator(componentMap[componentType]);
    lazyHydrator(element);
  });
});

Selective Event Binding

import { enableClientEvents } from 'coherent-js';

// Only enable events on specific parts of the page
document.addEventListener('DOMContentLoaded', () => {
  // Enable events only for interactive sections
  const interactiveSections = [
    document.querySelector('.interactive-form'),
    document.querySelector('.navigation'),
    document.querySelector('.sidebar')
  ];
  
  interactiveSections.forEach(section => {
    if (section) {
      enableClientEvents(section);
    }
  });
});

Hydration Error Handling

const safeHydrate = async (element, component, props = {}) => {
  try {
    const instance = await hydrate(element, component, props);
    console.log('Successfully hydrated:', component.name || 'Anonymous component');
    return instance;
  } catch (error) {
    console.error('Hydration failed for element:', element, error);
    
    // Add error indicator to element
    element.classList.add('hydration-failed');
    element.title = 'Interactive features unavailable';
    
    // Optionally report error to monitoring service
    if (window.errorReporting) {
      window.errorReporting.captureException(error, {
        tags: {
          type: 'hydration-error',
          component: component.name || 'unknown'
        }
      });
    }
    
    return null;
  }
};

// Batch hydration with error handling
const hydrateWithRetry = async (elements, components, propsArray = []) => {
  const results = [];
  
  for (let i = 0; i < elements.length; i++) {
    const element = elements[i];
    const component = components[i];
    const props = propsArray[i] || {};
    
    try {
      const instance = await safeHydrate(element, component, props);
      results.push(instance);
    } catch (error) {
      console.warn(`Skipping hydration for element ${i}:`, error);
      results.push(null);
    }
  }
  
  return results;
};

Best Practices

1. Server-Client Code Sharing

// shared/components.js - Works on both server and client
export const Button = ({ text, onClick, variant = 'primary' }) => ({
  button: {
    className: `btn btn--${variant}`,
    onclick: onClick,
    text: text
  }
});

// Only attach event listeners on client
if (typeof window !== 'undefined') {
  // Client-only code
}

// Only use Node.js APIs on server
if (typeof process !== 'undefined') {
  // Server-only code
}

2. Progressive Enhancement

// Ensure base functionality works without JavaScript
const ProgressiveForm = ({ action, method = 'POST' }) => ({
  form: {
    action: action,      // Works without JS
    method: method,      // Works without JS
    className: 'progressive-form',
    onsubmit: (e) => {   // Enhanced with JS
      e.preventDefault();
      // AJAX submission
      handleAjaxSubmit(e);
    },
    children: [
      // Form fields...
    ]
  }
});

3. Hydration Data Management

// Serialize complex data safely
const serializeHydrationData = (data) => {
  return JSON.stringify(data, (key, value) => {
    // Handle dates
    if (value instanceof Date) {
      return { __type: 'Date', value: value.toISOString() };
    }
    
    // Handle functions (remove them)
    if (typeof value === 'function') {
      return undefined;
    }
    
    return value;
  });
};

const deserializeHydrationData = (json) => {
  return JSON.parse(json, (key, value) => {
    // Restore dates
    if (value && value.__type === 'Date') {
      return new Date(value.value);
    }
    
    return value;
  });
};

Next Steps