-
Notifications
You must be signed in to change notification settings - Fork 8
Reactivity
Complete Guide: From Basic Reactivity to Advanced Async Patterns
Juris provides a powerful async-first reactivity system that handles both synchronous and asynchronous state changes seamlessly. Unlike traditional frameworks that treat async as an afterthought, Juris is built from the ground up to handle promises, async functions, and time-dependent data naturally.
- Function-based Reactivity: Properties become reactive when defined as functions
- Automatic Promise Handling: Async functions and promises are handled automatically
- Non-blocking Rendering: UI remains responsive during async operations
- Smart Placeholders: Automatic loading states and error handling
- Dependency Tracking: Automatic subscription to state changes
A simple rule to Juris reactivity: Reactivity works when getState is called from intended functional attributes and children.
// ❌ Static - Not reactive juris.registerComponent('Counter', (props, context) => { const count = context.getState('counter', 0); // Called once return { div: { text: `Count: ${count}`, // Never updates children: [ { button: { text: 'Increment', onclick: () => context.setState('counter', count + 1) } } ] } }; });
// ✅ Reactive - Updates when state changes juris.registerComponent('Counter', (props, context) => { return { div: { text: () => `Count: ${context.getState('counter', 0)}`, // Reactive function children: [ { button: { text: 'Increment', onclick: () => { const current = context.getState('counter', 0); context.setState('counter', current + 1); } } } ] } }; });
All component properties can be made reactive by using functions:
juris.registerComponent('ReactiveElement', (props, context) => { return { div: { // Reactive text text: () => context.getState('message', 'Hello'), // Reactive styling style: () => ({ color: context.getState('theme.color', 'black'), backgroundColor: context.getState('theme.bg', 'white') }), // Reactive class names className: () => { const isActive = context.getState('ui.isActive', false); return `element ${isActive ? 'active' : 'inactive'}`; }, // Reactive children children: () => { const items = context.getState('items', []); return items.map(item => ({ span: { text: item.name, key: item.id } })); } } }; });
juris.registerComponent('ConditionalComponent', (props, context) => { return { div: { children: () => { const isLoggedIn = context.getState('user.isLoggedIn', false); const userType = context.getState('user.type', 'guest'); if (!isLoggedIn) { return [{ LoginForm: {} }]; } switch (userType) { case 'admin': return [{ AdminDashboard: {} }]; case 'moderator': return [{ ModeratorPanel: {} }]; default: return [{ UserDashboard: {} }]; } } } }; });
Juris automatically detects and handles promises in:
- Component functions
- Property functions
- State values
- Event handlers
// Automatic promise detection const isPromise = value => value?.then; // Juris wraps all potential promises const promisify = result => { const promise = result?.then ? result : Promise.resolve(result); // ... tracking and handling logic return promise; };
// Juris tracks active promises const createPromisify = () => { const activePromises = new Set(); let isTracking = false; const subscribers = new Set(); const trackingPromisify = result => { const promise = result?.then ? result : Promise.resolve(result); if (isTracking && promise !== result) { activePromises.add(promise); promise.finally(() => { activePromises.delete(promise); setTimeout(checkAllComplete, 0); }); } return promise; }; return { promisify: trackingPromisify, startTracking, stopTracking, onAllComplete }; };
When async operations are detected, Juris automatically:
- Creates loading placeholders
- Replaces placeholders with resolved content
- Handles errors with error placeholders
- Manages cleanup when components unmount
// Component function returns a promise juris.registerComponent('AsyncUserProfile', async (props, context) => { // Async data fetching const user = await fetch(`/api/users/${props.userId}`).then(r => r.json()); const preferences = await fetch(`/api/users/${props.userId}/preferences`).then(r => r.json()); return { div: { className: 'user-profile', children: [ { h2: { text: user.name } }, { p: { text: user.email } }, { UserPreferences: { preferences } } ] } }; }); // Usage - automatic loading placeholder { AsyncUserProfile: { userId: 123 } } // Shows "Loading AsyncUserProfile..." until resolved
// Components can receive async props juris.registerComponent('DataDisplay', (props, context) => { return { div: { // Async props are automatically resolved text: props.asyncData, // If this is a promise, shows loading then resolves className: 'data-display' } }; }); // Usage with async props const asyncData = fetch('/api/data').then(r => r.json().then(data => data.message)); { DataDisplay: { asyncData } } // Automatic async prop handling
juris.registerComponent('LiveDataComponent', (props, context) => { return { div: { // Async reactive text text: async () => { const userId = context.getState('user.currentId'); if (!userId) return 'No user selected'; try { const response = await fetch(`/api/users/${userId}/status`); const data = await response.json(); return `Status: ${data.status}`; } catch (error) { return `Error: ${error.message}`; } }, // Async reactive children children: async () => { const filter = context.getState('filter.current', 'all'); const response = await fetch(`/api/items?filter=${filter}`); const items = await response.json(); return items.map(item => ({ ItemCard: { key: item.id, item } })); } } }; });
juris.registerComponent('AsyncForm', (props, context) => { return { form: { onsubmit: async (e) => { e.preventDefault(); // Set loading state context.setState('form.isSubmitting', true); try { const formData = new FormData(e.target); const response = await fetch('/api/submit', { method: 'POST', body: formData }); if (response.ok) { const result = await response.json(); context.setState('form.result', result); context.setState('form.success', true); } else { throw new Error('Submission failed'); } } catch (error) { context.setState('form.error', error.message); } finally { context.setState('form.isSubmitting', false); } }, children: () => { const isSubmitting = context.getState('form.isSubmitting', false); const error = context.getState('form.error', null); const success = context.getState('form.success', false); return [ { input: { type: 'text', name: 'data', required: true } }, { button: { type: 'submit', disabled: isSubmitting, text: isSubmitting ? 'Submitting...' : 'Submit' } }, ...(error ? [{ div: { className: 'error', text: error } }] : []), ...(success ? [{ div: { className: 'success', text: 'Success!' } }] : []) ]; } } }; });
// Advanced async state management with caching juris.registerComponent('CachedDataComponent', (props, context) => { return { div: { children: async () => { const cacheKey = `data_${props.id}`; const cached = context.getState(`cache.${cacheKey}`, null); const cacheTime = context.getState(`cache.${cacheKey}_time`, 0); const now = Date.now(); // Use cache if less than 5 minutes old if (cached && (now - cacheTime) < 300000) { return cached.map(item => ({ ItemCard: { key: item.id, item } })); } // Fetch new data try { const response = await fetch(`/api/data/${props.id}`); const data = await response.json(); // Cache the result context.setState(`cache.${cacheKey}`, data); context.setState(`cache.${cacheKey}_time`, now); return data.map(item => ({ ItemCard: { key: item.id, item } })); } catch (error) { // Return cached data on error, or empty array return cached ? cached.map(item => ({ ItemCard: { key: item.id, item } })) : []; } } } }; });
// Debounced search with async operations juris.registerComponent('SearchComponent', (props, context) => { let searchTimeout; return { div: { children: [ { input: { type: 'text', placeholder: 'Search...', oninput: (e) => { const query = e.target.value; // Clear previous timeout clearTimeout(searchTimeout); if (!query.trim()) { context.setState('search.results', []); context.setState('search.isSearching', false); return; } // Set searching state immediately context.setState('search.isSearching', true); // Debounce the actual search searchTimeout = setTimeout(async () => { try { const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); const results = await response.json(); context.setState('search.results', results); } catch (error) { context.setState('search.error', error.message); } finally { context.setState('search.isSearching', false); } }, 300); } } }, { div: { className: 'search-results', children: () => { const isSearching = context.getState('search.isSearching', false); const results = context.getState('search.results', []); const error = context.getState('search.error', null); if (isSearching) { return [{ div: { className: 'loading', text: 'Searching...' } }]; } if (error) { return [{ div: { className: 'error', text: `Error: ${error}` } }]; } if (results.length === 0) { return [{ div: { className: 'empty', text: 'No results found' } }]; } return results.map(result => ({ SearchResult: { key: result.id, result } })); } } } ] } }; });
// Component handling multiple parallel async operations juris.registerComponent('DashboardWidget', (props, context) => { return { div: { className: 'dashboard-widget', children: async () => { const userId = context.getState('user.id'); if (!userId) return [{ div: { text: 'Please log in' } }]; try { // Parallel async operations const [userData, statsData, notificationsData] = await Promise.all([ fetch(`/api/users/${userId}`).then(r => r.json()), fetch(`/api/users/${userId}/stats`).then(r => r.json()), fetch(`/api/users/${userId}/notifications`).then(r => r.json()) ]); return [ { UserInfo: { user: userData } }, { StatsPanel: { stats: statsData } }, { NotificationsList: { notifications: notificationsData } } ]; } catch (error) { return [{ div: { className: 'error', text: `Failed to load dashboard: ${error.message}` } }]; } } } }; });
// Component with custom loading indicators juris.registerComponent('CustomAsyncComponent', (props, context) => { return { render: async () => { // Custom loading indicator const loadingIndicator = { div: { className: 'custom-loading', children: [ { div: { className: 'spinner' } }, { p: { text: 'Loading custom data...' } } ] } }; try { const data = await fetch('/api/complex-data').then(r => r.json()); return { div: { className: 'loaded-content', children: [ { h2: { text: data.title } }, { ComplexDataViz: { data: data.visualization } } ] } }; } catch (error) { return { div: { className: 'error-state', children: [ { h3: { text: 'Oops! Something went wrong' } }, { p: { text: error.message } }, { button: { text: 'Retry', onclick: () => context.setState('forceRefresh', Date.now()) } } ] } }; } }, // Custom loading indicator while render function executes indicator: { div: { className: 'custom-loading', children: [ { div: { className: 'spinner' } }, { p: { text: 'Loading custom data...' } } ] } } }; });
// Component with real-time updates using WebSocket juris.registerComponent('LiveFeed', (props, context) => { return { hooks: { onMount: () => { const ws = new WebSocket('ws://localhost:8080/feed'); ws.onmessage = (event) => { const data = JSON.parse(event.data); const currentFeed = context.getState('feed.items', []); context.setState('feed.items', [data, ...currentFeed.slice(0, 49)]); // Keep last 50 }; ws.onopen = () => context.setState('feed.connected', true); ws.onclose = () => context.setState('feed.connected', false); ws.onerror = (error) => context.setState('feed.error', error.message); // Store ws reference for cleanup context.setState('feed.websocket', ws); }, onUnmount: () => { const ws = context.getState('feed.websocket'); if (ws) { ws.close(); context.setState('feed.websocket', null); } } }, render: () => { return { div: { className: 'live-feed', children: () => { const connected = context.getState('feed.connected', false); const items = context.getState('feed.items', []); const error = context.getState('feed.error', null); return [ { div: { className: `status ${connected ? 'connected' : 'disconnected'}`, text: connected ? '🟢 Live' : '🔴 Disconnected' } }, ...(error ? [{ div: { className: 'error', text: `Error: ${error}` } }] : []), { div: { className: 'feed-items', children: items.map(item => ({ FeedItem: { key: item.id, item } })) } } ]; } } }; } }; });
// Implement smart caching for async operations const createAsyncCache = (context) => { const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes return { async get(key, fetcher) { const cached = context.getState(`cache.${key}`, null); const timestamp = context.getState(`cache.${key}_timestamp`, 0); if (cached && (Date.now() - timestamp) < CACHE_DURATION) { return cached; } const data = await fetcher(); context.setState(`cache.${key}`, data); context.setState(`cache.${key}_timestamp`, Date.now()); return data; }, invalidate(key) { context.setState(`cache.${key}`, null); context.setState(`cache.${key}_timestamp`, 0); } }; }; // Usage in component juris.registerComponent('CachedComponent', (props, context) => { const cache = createAsyncCache(context); return { div: { children: async () => { return await cache.get(`data_${props.id}`, async () => { const response = await fetch(`/api/data/${props.id}`); return response.json(); }); } } }; });
// Async error boundary pattern juris.registerComponent('AsyncErrorBoundary', (props, context) => { return { div: { className: 'error-boundary', children: async () => { try { // Wrap async children in error handling const children = await Promise.resolve(props.children); context.setState('errorBoundary.hasError', false); return Array.isArray(children) ? children : [children]; } catch (error) { console.error('AsyncErrorBoundary caught error:', error); context.setState('errorBoundary.hasError', true); context.setState('errorBoundary.error', error.message); return [{ div: { className: 'error-fallback', children: [ { h3: { text: 'Something went wrong' } }, { p: { text: error.message } }, { button: { text: 'Try Again', onclick: () => { context.setState('errorBoundary.hasError', false); context.setState('errorBoundary.error', null); } } } ] } }]; } } } }; });
// Prevent unnecessary async operations juris.registerComponent('OptimizedAsyncComponent', (props, context) => { return { div: { children: async () => { const dependencies = { userId: context.getState('user.id'), filter: context.getState('filter.current'), sortBy: context.getState('sort.field') }; // Create dependency hash to avoid redundant calls const depHash = JSON.stringify(dependencies); const lastHash = context.getState('component.lastDepHash', ''); if (depHash === lastHash) { // Return cached result if dependencies haven't changed return context.getState('component.lastResult', []); } const result = await fetch('/api/data', { method: 'POST', body: JSON.stringify(dependencies) }).then(r => r.json()); // Cache result and dependencies context.setState('component.lastResult', result); context.setState('component.lastDepHash', depHash); return result.map(item => ({ ItemCard: { key: item.id, item } })); } } }; });
// Proper cleanup for async operations juris.registerComponent('AsyncComponentWithCleanup', (props, context) => { let abortController; return { hooks: { onMount: () => { abortController = new AbortController(); }, onUnmount: () => { if (abortController) { abortController.abort(); } } }, render: () => ({ div: { children: async () => { try { const response = await fetch('/api/data', { signal: abortController.signal }); if (response.ok) { const data = await response.json(); return data.map(item => ({ ItemCard: { item } })); } } catch (error) { if (error.name === 'AbortError') { return [{ div: { text: 'Request cancelled' } }]; } throw error; } } } }) }; });
Problem: Async function runs once but doesn't update when state changes.
// ❌ Wrong - getState called outside reactive function juris.registerComponent('BrokenAsync', (props, context) => { const userId = context.getState('user.id'); // Called once return { div: { text: async () => { const response = await fetch(`/api/users/${userId}`); // userId never updates const user = await response.json(); return user.name; } } }; }); // ✅ Correct - getState called inside reactive function juris.registerComponent('WorkingAsync', (props, context) => { return { div: { text: async () => { const userId = context.getState('user.id'); // Called on every update if (!userId) return 'No user'; const response = await fetch(`/api/users/${userId}`); const user = await response.json(); return user.name; } } }; });
Problem: Async operations continue after component unmount.
// ❌ Wrong - potential memory leak juris.registerComponent('LeakyComponent', (props, context) => { return { div: { children: async () => { // This continues even if component unmounts await new Promise(resolve => setTimeout(resolve, 5000)); const data = await fetch('/api/data').then(r => r.json()); return data.map(item => ({ ItemCard: { item } })); } } }; }); // ✅ Correct - proper cleanup juris.registerComponent('CleanComponent', (props, context) => { let isMounted = true; return { hooks: { onUnmount: () => { isMounted = false; } }, render: () => ({ div: { children: async () => { await new Promise(resolve => setTimeout(resolve, 5000)); if (!isMounted) return []; // Check if still mounted const data = await fetch('/api/data').then(r => r.json()); return data.map(item => ({ ItemCard: { item } })); } } }) }; });
Problem: Async function triggers its own state changes causing loops.
// ❌ Wrong - infinite loop juris.registerComponent('InfiniteLoop', (props, context) => { return { div: { children: async () => { const count = context.getState('count', 0); context.setState('count', count + 1); // This triggers another render! const data = await fetch('/api/data').then(r => r.json()); return data.map(item => ({ ItemCard: { item } })); } } }; }); // ✅ Correct - separate state updates from reactive functions juris.registerComponent('NoLoop', (props, context) => { return { div: { children: [ { button: { text: 'Increment', onclick: () => { const count = context.getState('count', 0); context.setState('count', count + 1); // State update in event handler } } }, { div: { children: async () => { const count = context.getState('count', 0); // Only reads state const data = await fetch(`/api/data?count=${count}`).then(r => r.json()); return data.map(item => ({ ItemCard: { item } })); } } } ] } }; });
// Enable detailed logging for async operations const juris = new Juris({ logLevel: 'debug', // ... other config }); // Subscribe to log messages juris.logger.subscribe((message, category) => { if (category === 'async') { console.log('Async operation:', message); } });
// Add debug information to async components juris.registerComponent('DebuggableAsync', (props, context) => { return { div: { children: async () => { const startTime = Date.now(); console.log('Async render started'); try { const data = await fetch('/api/data').then(r => r.json()); const duration = Date.now() - startTime; console.log(`Async render completed in ${duration}ms`); // Store debug info in state context.setState('debug.lastRenderTime', duration); context.setState('debug.lastRenderData', data.length); return data.map(item => ({ ItemCard: { item } })); } catch (error) { console.error('Async render failed:', error); context.setState('debug.lastError', error.message); throw error; } } } }; });
juris.registerComponent('ProductList', (props, context) => { let searchTimeout; return { div: { className: 'product-list', children: [ // Search input with debouncing { div: { className: 'search-bar', children: [ { input: { type: 'text', placeholder: 'Search products...', oninput: (e) => { const query = e.target.value; clearTimeout(searchTimeout); context.setState('search.isSearching', true); searchTimeout = setTimeout(async () => { if (query.trim()) { try { const response = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`); const products = await response.json(); context.setState('products.list', products); context.setState('search.hasSearched', true); } catch (error) { context.setState('search.error', error.message); } } else { context.setState('products.list', []); context.setState('search.hasSearched', false); } context.setState('search.isSearching', false); }, 300); } } }, { div: { className: 'search-status', text: () => { const isSearching = context.getState('search.isSearching', false); const hasSearched = context.getState('search.hasSearched', false); const error = context.getState('search.error', null); if (error) return `Error: ${error}`; if (isSearching) return 'Searching...'; if (hasSearched) return 'Search complete'; return 'Type to search'; } } } ] } }, // Product grid with async loading { div: { className: 'product-grid', children: async () => { const products = context.getState('products.list', []); const sortBy = context.getState('sort.field', 'name'); const filterCategory = context.getState('filter.category', 'all'); if (products.length === 0) { return [{ div: { className: 'empty', text: 'No products found' } }]; } // Async sorting and filtering const processedProducts = await new Promise(resolve => { setTimeout(() => { let filtered = products; if (filterCategory !== 'all') { filtered = products.filter(p => p.category === filterCategory); } const sorted = [...filtered].sort((a, b) => { switch (sortBy) { case 'price': return a.price - b.price; case 'rating': return b.rating - a.rating; default: return a.name.localeCompare(b.name); } }); resolve(sorted); }, 100); // Simulate processing time }); return processedProducts.map(product => ({ ProductCard: { key: product.id, product } })); } } } ] } }; });
juris.registerComponent('ChatRoom', (props, context) => { let ws; let reconnectAttempts = 0; const maxReconnectAttempts = 5; const connectWebSocket = () => { ws = new WebSocket(`ws://localhost:8080/chat/${props.roomId}`); ws.onopen = () => { console.log('Chat connected'); context.setState('chat.connected', true); context.setState('chat.error', null); reconnectAttempts = 0; }; ws.onmessage = (event) => { const message = JSON.parse(event.data); const messages = context.getState('chat.messages', []); context.setState('chat.messages', [...messages, message]); }; ws.onclose = () => { context.setState('chat.connected', false); // Auto-reconnect if (reconnectAttempts < maxReconnectAttempts) { reconnectAttempts++; setTimeout(connectWebSocket, 1000 * reconnectAttempts); } }; ws.onerror = (error) => { context.setState('chat.error', 'Connection failed'); }; }; return { hooks: { onMount: connectWebSocket, onUnmount: () => { if (ws) ws.close(); } }, render: () => ({ div: { className: 'chat-room', children: [ // Connection status { div: { className: 'chat-status', children: () => { const connected = context.getState('chat.connected', false); const error = context.getState('chat.error', null); if (error) { return [{ span: { className: 'error', text: `❌ ${error}` } }]; } return [{ span: { className: connected ? 'connected' : 'disconnected', text: connected ? '🟢 Connected' : '🔴 Disconnected' } }]; } } }, // Messages list with auto-scroll { div: { className: 'messages', children: () => { const messages = context.getState('chat.messages', []); return messages.map(message => ({ ChatMessage: { key: message.id, message } })); } } }, // Message input { form: { className: 'message-form', onsubmit: async (e) => { e.preventDefault(); const input = e.target.querySelector('input'); const text = input.value.trim(); if (text && ws && ws.readyState === WebSocket.OPEN) { const message = { id: Date.now(), text, userId: context.getState('user.id'), timestamp: new Date().toISOString() }; ws.send(JSON.stringify(message)); input.value = ''; } }, children: () => { const connected = context.getState('chat.connected', false); return [ { input: { type: 'text', placeholder: connected ? 'Type a message...' : 'Connecting...', disabled: !connected } }, { button: { type: 'submit', text: 'Send', disabled: !connected } } ]; } } } ] } }) }; });
juris.registerComponent('DataVisualization', (props, context) => { return { div: { className: 'data-viz', children: async () => { const timeRange = context.getState('viz.timeRange', '7d'); const dataType = context.getState('viz.dataType', 'revenue'); const refreshTrigger = context.getState('viz.refreshTrigger', 0); try { // Parallel data fetching const [mainData, comparativeData, metadata] = await Promise.all([ fetch(`/api/analytics/${dataType}?range=${timeRange}`).then(r => r.json()), fetch(`/api/analytics/${dataType}/comparative?range=${timeRange}`).then(r => r.json()), fetch(`/api/analytics/metadata?type=${dataType}`).then(r => r.json()) ]); // Process data for visualization const processedData = await new Promise(resolve => { // Simulate data processing setTimeout(() => { const processed = { main: mainData.map(d => ({ ...d, trend: d.value > d.previousValue ? 'up' : 'down' })), comparative: comparativeData, summary: { total: mainData.reduce((sum, d) => sum + d.value, 0), average: mainData.reduce((sum, d) => sum + d.value, 0) / mainData.length, change: mainData[mainData.length - 1]?.value - mainData[0]?.value } }; resolve(processed); }, 500); }); return [ // Summary cards { div: { className: 'summary-cards', children: [ { SummaryCard: { title: 'Total', value: processedData.summary.total, format: metadata.format } }, { SummaryCard: { title: 'Average', value: processedData.summary.average, format: metadata.format } }, { SummaryCard: { title: 'Change', value: processedData.summary.change, format: metadata.format, trend: processedData.summary.change > 0 ? 'positive' : 'negative' } } ] } }, // Main chart { ChartComponent: { data: processedData.main, type: metadata.chartType, options: metadata.chartOptions } }, // Comparative chart { ComparativeChart: { data: processedData.comparative, timeRange } } ]; } catch (error) { return [{ div: { className: 'error-state', children: [ { h3: { text: 'Failed to load data' } }, { p: { text: error.message } }, { button: { text: 'Retry', onclick: () => { const current = context.getState('viz.refreshTrigger', 0); context.setState('viz.refreshTrigger', current + 1); } } } ] } }]; } } } }; });
Juris provides a comprehensive async reactivity system that makes handling asynchronous operations natural and performant. Key takeaways:
- Function-based reactivity ensures components update when dependencies change
- Automatic promise handling removes boilerplate for async operations
- Smart placeholders provide excellent user experience during loading
- Proper cleanup prevents memory leaks and stale updates
- Caching strategies optimize performance for repeated operations
By following these patterns and best practices, you can build highly responsive and efficient applications that handle complex async scenarios gracefully.
- [Juris Official Documentation](https://jurisjs.com/)
- [GitHub Repository](https://github.com/jurisjs/juris)
- [Community Examples](https://github.com/jurisjs/examples)
- [Performance Benchmarks](https://jurisjs.com/benchmarks)
Remember: Async reactivity in Juris is about making asynchronous operations as simple and natural as synchronous ones, while maintaining excellent performance and user experience.