Alpine.js to Juris.js Migration: Pure, Debuggable, Islandable Solution
Pinoy Codie

Pinoy Codie @lynphp

About:

Joined:
Sep 2, 2023

Alpine.js to Juris.js Migration: Pure, Debuggable, Islandable Solution

Publish Date: Jun 21
4 3

Migrating from Alpine's template-based approach to Juris's pure JavaScript, debuggable, and islandable architecture


Why Migrate from Alpine.js to Juris.js?

Alpine.js is excellent for sprinkling interactivity into HTML, but Juris.js offers compelling advantages for modern development:

Alpine.js Limitations:

  • Template-bound logic - Business logic mixed with HTML attributes
  • Debugging challenges - Hard to debug x-data and x-show in DevTools
  • No true islands - Global Alpine state affects entire page
  • Limited composability - Difficult to share logic between components
  • Opaque reactivity - Can't easily inspect state changes

Juris.js Advantages:

  • Pure JavaScript - All logic in debuggable JS functions
  • True islands - Each enhancement is completely isolated
  • Transparent state - Clear state management with full debugging support
  • Service injection - Clean dependency injection pattern
  • Selector targeting - Precise DOM targeting without attribute pollution

Migration Philosophy: Islands over Globals

Alpine.js Global Approach:

<!-- Everything shares global Alpine context -->
<div x-data="{ count: 0 }">
  <button x-on:click="count++">Count: <span x-text="count"></span></button>
</div>

<div x-data="{ name: '' }">
  <input x-model="name" placeholder="Name">
  <p x-text="name"></p>
</div>
Enter fullscreen mode Exit fullscreen mode

Juris.js Island Approach:

<!-- Each island is independent and debuggable -->
<div class="counter-island">
  <button>Count: <span class="count-display">0</span></button>
</div>

<div class="name-island">
  <input class="name-input" placeholder="Name">
  <p class="name-display"></p>
</div>

<script src="https://unpkg.com/juris@0.5.2/juris.js"></script>
<script>
// Island 1: Counter (completely isolated)
const counterJuris = new Juris({
    states: { count: 0 }
});

counterJuris.enhance('.counter-island button', {
    onclick: () => {
        const current = counterJuris.getState('count');
        counterJuris.setState('count', current + 1);
    }
});

counterJuris.enhance('.count-display', {
    text: () => counterJuris.getState('count')
});

// Island 2: Name input (completely isolated)  
const nameJuris = new Juris({
    states: { name: '' }
});

nameJuris.enhance('.name-input', {
    oninput: (e) => nameJuris.setState('name', e.target.value)
});

nameJuris.enhance('.name-display', {
    text: () => nameJuris.getState('name')
});
</script>
Enter fullscreen mode Exit fullscreen mode

Key Benefits:

  • Debuggable: Each island can be inspected via counterJuris.getState('count')
  • Isolated: Islands don't affect each other
  • Pure JS: All logic in standard JavaScript functions
  • No attribute pollution: Clean HTML without x- attributes

Step 1: Understanding the Fundamental Differences

Alpine.js Reactive Model:

<div x-data="{ 
    items: [],
    newItem: '',
    addItem() {
        this.items.push({ id: Date.now(), text: this.newItem });
        this.newItem = '';
    }
}">
    <input x-model="newItem" placeholder="Add item">
    <button x-on:click="addItem()">Add</button>

    <template x-for="item in items">
        <div x-text="item.text"></div>
    </template>
</div>
Enter fullscreen mode Exit fullscreen mode

Juris.js Reactive Model:

<div class="todo-island">
    <input class="new-item" placeholder="Add item">
    <button class="add-button">Add</button>
    <div class="items-container"></div>
</div>

<script>
const todoIsland = new Juris({
    states: {
        items: [],
        newItem: ''
    },
    services: {
        todoService: {
            addItem: () => {
                const newItem = todoIsland.getState('newItem');
                const items = todoIsland.getState('items');

                if (newItem.trim()) {
                    todoIsland.setState('items', [
                        ...items, 
                        { id: Date.now(), text: newItem }
                    ]);
                    todoIsland.setState('newItem', '');
                }
            }
        }
    }
});

// Pure, debuggable enhancements
todoIsland.enhance('.new-item', {
    value: () => todoIsland.getState('newItem'),
    oninput: (e) => todoIsland.setState('newItem', e.target.value)
});

todoIsland.enhance('.add-button', {
    onclick: ({ todoService }) => todoService.addItem()
});

todoIsland.enhance('.items-container', {
    children: () => {
        const items = todoIsland.getState('items');
        return items.map(item => ({
            div: { text: item.text, key: item.id }
        }));
    }
});
</script>
Enter fullscreen mode Exit fullscreen mode

Debugging Comparison:

// Alpine.js debugging (limited)
// Have to use Alpine.store() or inspect $data in browser
console.log('Alpine state:', document.querySelector('[x-data]').__alpine__.$data);

// Juris.js debugging (transparent)
console.log('Todo state:', todoIsland.getState('todos'));
console.log('Items:', todoIsland.getState('items'));
console.log('New item:', todoIsland.getState('newItem'));

// Live state inspection
todoIsland.subscribe('items', (items) => {
    console.log('Items changed:', items);
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Migration Patterns by Use Case

2.1 Simple Toggle Components

Alpine.js Version:

<div x-data="{ open: false }">
    <button x-on:click="open = !open">
        <span x-text="open ? 'Close' : 'Open'"></span>
    </button>
    <div x-show="open" x-transition>
        <p>Content is now visible!</p>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Juris.js Island Version:

<div class="toggle-island">
    <button class="toggle-button">Open</button>
    <div class="toggle-content" style="display: none;">
        <p>Content is now visible!</p>
    </div>
</div>

<script>
const toggleIsland = new Juris({
    states: { open: false }
});

toggleIsland.enhance('.toggle-button', {
    text: () => toggleIsland.getState('open') ? 'Close' : 'Open',
    onclick: () => {
        const current = toggleIsland.getState('open');
        toggleIsland.setState('open', !current);
    }
});

toggleIsland.enhance('.toggle-content', {
    style: () => ({
        display: toggleIsland.getState('open') ? 'block' : 'none',
        transition: 'all 0.3s ease'
    })
});

// Debug the toggle state anytime
console.log('Toggle state:', toggleIsland.getState('open'));
</script>
Enter fullscreen mode Exit fullscreen mode

2.2 Form Handling with Validation

Alpine.js Version:

<form x-data="{
    email: '',
    password: '',
    errors: {},

    validate() {
        this.errors = {};
        if (!this.email.includes('@')) {
            this.errors.email = 'Invalid email';
        }
        if (this.password.length < 6) {
            this.errors.password = 'Password too short';
        }
    },

    submit() {
        this.validate();
        if (Object.keys(this.errors).length === 0) {
            alert('Form submitted!');
        }
    }
}" x-on:submit.prevent="submit()">

    <input x-model="email" placeholder="Email" 
           x-on:blur="validate()" 
           :class="{ 'error': errors.email }">
    <span x-show="errors.email" x-text="errors.email"></span>

    <input x-model="password" type="password" placeholder="Password"
           x-on:blur="validate()"
           :class="{ 'error': errors.password }">
    <span x-show="errors.password" x-text="errors.password"></span>

    <button type="submit" :disabled="Object.keys(errors).length > 0">
        Submit
    </button>
</form>
Enter fullscreen mode Exit fullscreen mode

Juris.js Island Version:

<form class="login-island">
    <input name="email" placeholder="Email" class="email-input">
    <span class="email-error" style="display: none;"></span>

    <input name="password" type="password" placeholder="Password" class="password-input">
    <span class="password-error" style="display: none;"></span>

    <button type="submit" class="submit-button">Submit</button>
</form>

<script>
const loginIsland = new Juris({
    states: {
        email: '',
        password: '',
        errors: {}
    },
    services: {
        validator: {
            validateEmail: (email) => {
                const error = !email.includes('@') ? 'Invalid email' : null;
                loginIsland.setState('errors.email', error);
                return !error;
            },

            validatePassword: (password) => {
                const error = password.length < 6 ? 'Password too short' : null;
                loginIsland.setState('errors.password', error);
                return !error;
            },

            isFormValid: () => {
                const errors = loginIsland.getState('errors');
                return !errors.email && !errors.password;
            }
        },

        formHandler: {
            submit: () => {
                const { validator } = loginIsland.services;
                const email = loginIsland.getState('email');
                const password = loginIsland.getState('password');

                const emailValid = validator.validateEmail(email);
                const passwordValid = validator.validatePassword(password);

                if (emailValid && passwordValid) {
                    alert('Form submitted!');
                    // Debug: inspect form state
                    console.log('Form data:', { email, password });
                }
            }
        }
    }
});

// Email input enhancement
loginIsland.enhance('.email-input', {
    value: () => loginIsland.getState('email'),
    oninput: (e) => loginIsland.setState('email', e.target.value),
    onblur: ({ validator }) => validator.validateEmail(loginIsland.getState('email')),
    className: () => loginIsland.getState('errors.email') ? 'error' : ''
});

// Email error display
loginIsland.enhance('.email-error', {
    text: () => loginIsland.getState('errors.email', ''),
    style: () => ({
        display: loginIsland.getState('errors.email') ? 'block' : 'none',
        color: 'red'
    })
});

// Password input enhancement
loginIsland.enhance('.password-input', {
    value: () => loginIsland.getState('password'),
    oninput: (e) => loginIsland.setState('password', e.target.value),
    onblur: ({ validator }) => validator.validatePassword(loginIsland.getState('password')),
    className: () => loginIsland.getState('errors.password') ? 'error' : ''
});

// Password error display
loginIsland.enhance('.password-error', {
    text: () => loginIsland.getState('errors.password', ''),
    style: () => ({
        display: loginIsland.getState('errors.password') ? 'block' : 'none',
        color: 'red'
    })
});

// Submit button enhancement
loginIsland.enhance('.submit-button', {
    disabled: ({ validator }) => !validator.isFormValid(),
    onclick: (e, { formHandler }) => {
        e.preventDefault();
        formHandler.submit();
    }
});

// Debug form state anytime
console.log('Login form state:', loginIsland.getState('login'));
console.log('Form errors:', loginIsland.getState('errors'));
</script>
Enter fullscreen mode Exit fullscreen mode

2.3 Dynamic Lists with State Management

Alpine.js Version:

<div x-data="{
    todos: [
        { id: 1, text: 'Learn Alpine', done: false },
        { id: 2, text: 'Build app', done: true }
    ],
    newTodo: '',

    addTodo() {
        if (this.newTodo.trim()) {
            this.todos.push({
                id: Date.now(),
                text: this.newTodo,
                done: false
            });
            this.newTodo = '';
        }
    },

    toggleTodo(id) {
        const todo = this.todos.find(t => t.id === id);
        todo.done = !todo.done;
    },

    removeTodo(id) {
        this.todos = this.todos.filter(t => t.id !== id);
    }
}">

    <input x-model="newTodo" placeholder="Add todo" x-on:keyup.enter="addTodo()">
    <button x-on:click="addTodo()">Add</button>

    <template x-for="todo in todos" :key="todo.id">
        <div :class="{ 'done': todo.done }">
            <span x-text="todo.text"></span>
            <input type="checkbox" x-model="todo.done">
            <button x-on:click="removeTodo(todo.id)">Remove</button>
        </div>
    </template>
</div>
Enter fullscreen mode Exit fullscreen mode

Juris.js Island Version:

<div class="todos-island">
    <div class="todo-input-section">
        <input class="new-todo" placeholder="Add todo">
        <button class="add-todo">Add</button>
    </div>
    <div class="todos-list"></div>
    <div class="todos-stats"></div>
</div>

<script>
const todosIsland = new Juris({
    states: {
        todos: [
            { id: 1, text: 'Learn Juris', done: false },
            { id: 2, text: 'Build app', done: true }
        ],
        newTodo: ''
    },
    services: {
        todoManager: {
            addTodo: () => {
                const newTodo = todosIsland.getState('newTodo');
                if (newTodo.trim()) {
                    const todos = todosIsland.getState('todos');
                    todosIsland.setState('todos', [
                        ...todos,
                        { id: Date.now(), text: newTodo, done: false }
                    ]);
                    todosIsland.setState('newTodo', '');
                }
            },

            toggleTodo: (id) => {
                const todos = todosIsland.getState('todos');
                const updated = todos.map(todo => 
                    todo.id === id ? { ...todo, done: !todo.done } : todo
                );
                todosIsland.setState('todos', updated);
            },

            removeTodo: (id) => {
                const todos = todosIsland.getState('todos');
                todosIsland.setState('todos', todos.filter(t => t.id !== id));
            },

            getStats: () => {
                const todos = todosIsland.getState('todos');
                return {
                    total: todos.length,
                    done: todos.filter(t => t.done).length,
                    remaining: todos.filter(t => !t.done).length
                };
            }
        }
    }
});

// New todo input
todosIsland.enhance('.new-todo', {
    value: () => todosIsland.getState('newTodo'),
    oninput: (e) => todosIsland.setState('newTodo', e.target.value),
    onkeyup: (e, { todoManager }) => {
        if (e.key === 'Enter') {
            todoManager.addTodo();
        }
    }
});

// Add button
todosIsland.enhance('.add-todo', {
    onclick: ({ todoManager }) => todoManager.addTodo()
});

// Todos list (dynamic children)
todosIsland.enhance('.todos-list', ({ todoManager }) => ({
    children: () => {
        const todos = todosIsland.getState('todos');
        return todos.map(todo => ({
            div: {
                key: todo.id,
                className: todo.done ? 'todo-item done' : 'todo-item',
                children: [
                    { span: { text: todo.text } },
                    {
                        input: {
                            type: 'checkbox',
                            checked: todo.done,
                            onchange: () => todoManager.toggleTodo(todo.id)
                        }
                    },
                    {
                        button: {
                            text: 'Remove',
                            onclick: () => todoManager.removeTodo(todo.id)
                        }
                    }
                ]
            }
        }));
    }
}));

// Live stats
todosIsland.enhance('.todos-stats', ({ todoManager }) => ({
    text: () => {
        const stats = todoManager.getStats();
        return `${stats.remaining} of ${stats.total} remaining`;
    }
}));

// Debug todos state anytime
console.log('Todos state:', todosIsland.getState('todos'));
console.log('New todo:', todosIsland.getState('newTodo'));

// Live debugging - watch state changes
todosIsland.subscribe('todos', (todos) => {
    console.log('Todos updated:', todos);
});
</script>
Enter fullscreen mode Exit fullscreen mode

Step 3: Advanced Debugging Capabilities

3.1 State Inspection and Time-Travel

// Create a debug-enabled island
const debugTodosIsland = new Juris({
    states: {
        todos: [],
        filter: 'all'
    }
});

// State history for time-travel debugging
const stateHistory = [];
const maxHistory = 50;

// Intercept all state changes
const originalSetState = debugTodosIsland.setState.bind(debugTodosIsland);
debugTodosIsland.setState = function(path, value) {
    // Record state before change
    const snapshot = {
        timestamp: Date.now(),
        path,
        oldValue: this.getState(path),
        newValue: value,
        fullState: JSON.parse(JSON.stringify(this.state))
    };

    // Apply the change
    const result = originalSetState(path, value);

    // Record the change
    stateHistory.push(snapshot);
    if (stateHistory.length > maxHistory) {
        stateHistory.shift();
    }

    console.log('State change:', snapshot);
    return result;
};

// Debug utilities
window.debugTodos = {
    // Current state
    getState: () => debugTodosIsland.getState('todos'),

    // State history
    getHistory: () => stateHistory,

    // Time travel
    revertToState: (index) => {
        const snapshot = stateHistory[index];
        if (snapshot) {
            debugTodosIsland.state = JSON.parse(JSON.stringify(snapshot.fullState));
            console.log('Reverted to state:', snapshot);
        }
    },

    // Reset to initial state
    reset: () => {
        debugTodosIsland.setState('todos', []);
        debugTodosIsland.setState('filter', 'all');
    },

    // Performance monitoring
    getUpdateCount: () => stateHistory.length,
    getLastUpdate: () => stateHistory[stateHistory.length - 1]
};

console.log('Debug utilities available:', window.debugTodos);
Enter fullscreen mode Exit fullscreen mode

3.2 Island Communication Debugging

// Multi-island setup with debug communication
const headerIsland = new Juris({
    states: { user: null }
});

const sidebarIsland = new Juris({
    states: { menuOpen: false }
});

// Debug-enabled island communication
const islandCommunication = {
    channels: new Map(),

    // Subscribe to cross-island events
    subscribe: (channel, callback) => {
        if (!this.channels.has(channel)) {
            this.channels.set(channel, []);
        }
        this.channels.get(channel).push(callback);
        console.log(`Subscribed to channel: ${channel}`);
    },

    // Publish cross-island events
    publish: (channel, data) => {
        console.log(`Publishing to ${channel}:`, data);
        const callbacks = this.channels.get(channel) || [];
        callbacks.forEach(callback => {
            try {
                callback(data);
            } catch (error) {
                console.error(`Error in ${channel} callback:`, error);
            }
        });
    },

    // Debug island states
    debugAllIslands: () => {
        console.log('All island states:', {
            header: headerIsland.getState('header'),
            sidebar: sidebarIsland.getState('sidebar')
        });
    }
};

// Example: User login affects multiple islands
islandCommunication.subscribe('user-login', (user) => {
    headerIsland.setState('user', user);
    sidebarIsland.setState('menuOpen', true);
});

// Trigger cross-island communication
headerIsland.enhance('.login-button', {
    onclick: () => {
        const user = { name: 'John Doe', id: 1 };
        islandCommunication.publish('user-login', user);
    }
});

// Debug island communication
window.islandDebug = islandCommunication;
Enter fullscreen mode Exit fullscreen mode

Step 4: Performance and Bundle Comparison

4.1 Bundle Size Analysis

<!-- Alpine.js Setup -->
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<!-- Size: ~15KB gzipped -->

<!-- Juris.js Setup -->
<script src="https://unpkg.com/juris@0.5.2/juris.js"></script>
<!-- Size: ~25KB gzipped -->
Enter fullscreen mode Exit fullscreen mode

Trade-off Analysis:

  • Alpine.js: Smaller initial bundle, but template-bound logic
  • Juris.js: Slightly larger bundle, but pure JavaScript with full debugging

4.2 Performance Monitoring

// Performance monitoring for Juris islands
const performanceMonitor = {
    timers: new Map(),
    measurements: [],

    start: (label) => {
        performanceMonitor.timers.set(label, performance.now());
    },

    end: (label) => {
        const startTime = performanceMonitor.timers.get(label);
        if (startTime) {
            const duration = performance.now() - startTime;
            performanceMonitor.measurements.push({
                label,
                duration,
                timestamp: Date.now()
            });
            console.log(`${label}: ${duration.toFixed(2)}ms`);
            performanceMonitor.timers.delete(label);
        }
    },

    getReport: () => {
        const report = {};
        performanceMonitor.measurements.forEach(m => {
            if (!report[m.label]) {
                report[m.label] = [];
            }
            report[m.label].push(m.duration);
        });

        // Calculate averages
        Object.keys(report).forEach(label => {
            const times = report[label];
            report[label] = {
                count: times.length,
                average: times.reduce((a, b) => a + b, 0) / times.length,
                min: Math.min(...times),
                max: Math.max(...times)
            };
        });

        return report;
    }
};

// Monitor island creation performance
performanceMonitor.start('island-creation');
const performanceTestIsland = new Juris({
    states: { items: Array.from({length: 1000}, (_, i) => ({ id: i, text: `Item ${i}` })) }
});
performanceMonitor.end('island-creation');

// Monitor enhancement performance
performanceMonitor.start('enhancement-setup');
performanceTestIsland.enhance('.performance-test', {
    children: () => {
        const items = performanceTestIsland.getState('items');
        return items.slice(0, 100).map(item => ({
            div: { text: item.text, key: item.id }
        }));
    }
});
performanceMonitor.end('enhancement-setup');

// Get performance report
console.log('Performance Report:', performanceMonitor.getReport());
Enter fullscreen mode Exit fullscreen mode

Step 5: Migration Strategy and Timeline

5.1 Gradual Island-by-Island Migration

<!-- Phase 1: Keep Alpine for complex parts, start with simple islands -->
<div x-data="{ complexState: {...} }">
    <!-- Keep complex Alpine logic temporarily -->
    <div x-show="complexState.showAdvanced">...</div>
</div>

<!-- Convert simple toggles to Juris islands first -->
<div class="simple-toggle-island">
    <button class="toggle-btn">Toggle</button>
    <div class="toggle-content">Content</div>
</div>

<script>
// Simple island conversion
const toggleIsland = new Juris({
    states: { open: false }
});

toggleIsland.enhance('.toggle-btn', {
    onclick: () => {
        const current = toggleIsland.getState('open');
        toggleIsland.setState('open', !current);
    }
});

toggleIsland.enhance('.toggle-content', {
    style: () => ({
        display: toggleIsland.getState('open') ? 'block' : 'none'
    })
});
</script>
Enter fullscreen mode Exit fullscreen mode

5.2 Migration Priority Matrix

Component Type Complexity Debug Benefit Migration Priority
Simple toggles Low Medium High - Start here
Form validation Medium High High - Major debug benefit
Data lists Medium High Medium - Good debug benefit
Complex modals High High Medium - Plan carefully
Nested components High Medium Low - Migrate last

5.3 Side-by-Side Comparison Tool

<!DOCTYPE html>
<html>
<head>
    <title>Alpine vs Juris Comparison</title>
    <script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
    <script src="https://unpkg.com/juris@0.5.2/juris.js"></script>
    <style>
        .comparison { display: flex; gap: 20px; }
        .alpine-side, .juris-side { flex: 1; border: 1px solid #ccc; padding: 20px; }
        .debug-panel { background: #f5f5f5; padding: 10px; margin-top: 10px; }
    </style>
</head>
<body>
    <div class="comparison">
        <!-- Alpine.js side -->
        <div class="alpine-side">
            <h3>Alpine.js Version</h3>
            <div x-data="{ count: 0 }">
                <button x-on:click="count++">Alpine Count: <span x-text="count"></span></button>
                <div class="debug-panel">
                    <strong>Debug:</strong> Hard to inspect Alpine state
                </div>
            </div>
        </div>

        <!-- Juris.js side -->
        <div class="juris-side">
            <h3>Juris.js Island</h3>
            <div class="counter-island">
                <button class="juris-counter">Juris Count: <span class="count">0</span></button>
                <div class="debug-panel">
                    <strong>Debug:</strong> <button onclick="debugJuris()">Inspect State</button>
                </div>
            </div>
        </div>
    </div>

    <script>
    // Juris island setup
    const comparisonIsland = new Juris({
        states: { count: 0 }
    });

    comparisonIsland.enhance('.juris-counter', {
        onclick: () => {
            const current = comparisonIsland.getState('count');
            comparisonIsland.setState('count', current + 1);
        }
    });

    comparisonIsland.enhance('.count', {
        text: () => comparisonIsland.getState('count')
    });

    // Debug function
    function debugJuris() {
        console.log('Juris state:', comparisonIsland.getState('any'));
        alert(`Current count: ${comparisonIsland.getState('count')}`);
    }

    // Make debugging available globally
    window.jurisDebug = {
        getState: () => comparisonIsland.getState('any'),
        setState: (path, value) => comparisonIsland.setState(path, value),
        reset: () => comparisonIsland.setState('count', 0)
    };
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 6: Testing and Quality Assurance

6.1 Island Testing Framework

// Simple testing framework for Juris islands
const IslandTester = {
    tests: [],

    test: (name, islandFactory, testFn) => {
        IslandTester.tests.push({ name, islandFactory, testFn });
    },

    run: () => {
        console.log('Running island tests...');
        let passed = 0;
        let failed = 0;

        IslandTester.tests.forEach(({ name, islandFactory, testFn }) => {
            try {
                console.log(`Testing: ${name}`);
                const island = islandFactory();
                testFn(island);
                console.log(`✅ ${name} passed`);
                passed++;
            } catch (error) {
                console.error(`❌ ${name} failed:`, error);
                failed++;
            }
        });

        console.log(`Tests complete: ${passed} passed, ${failed} failed`);
    }
};

// Example tests
IslandTester.test('Counter increment', 
    () => new Juris({ states: { count: 0 } }),
    (island) => {
        // Test initial state
        if (island.getState('count') !== 0) {
            throw new Error('Initial count should be 0');
        }

        // Test state change
        island.setState('count', 5);
        if (island.getState('count') !== 5) {
            throw new Error('Count should be 5 after setState');
        }
    }
);

IslandTester.test('Todo management',
    () => new Juris({ 
        states: { todos: [] },
        services: {
            todoManager: {
                add: (text) => {
                    const todos = island.getState('todos');
                    island.setState('todos', [...todos, { id: Date.now(), text }]);
                }
            }
        }
    }),
    (island) => {
        // Test adding todos
        island.services.todoManager.add('Test todo');
        const todos = island.getState('todos');

        if (todos.length !== 1) {
            throw new Error('Should have 1 todo');
        }

        if (todos[0].text !== 'Test todo') {
            throw new Error('Todo text should match');
        }
    }
);

// Run tests
IslandTester.run();
Enter fullscreen mode Exit fullscreen mode

6.2 Debug Console Integration

// Enhanced debug console for production debugging
const JurisDebugConsole = {
    islands: new Map(),

    register: (name, island) => {
        JurisDebugConsole.islands.set(name, island);
        console.log(`Registered island: ${name}`);
    },

    inspect: (islandName) => {
        const island = JurisDebugConsole.islands.get(islandName);
        if (island) {
            return {
                state: island.getState('any'),
                services: Object.keys(island.services || {}),
                enhancementStats: island.getEnhancementStats?.() || 'Not available'
            };
        }
        return null;
    },

    listIslands: () => Array.from(JurisDebugConsole.islands.keys()),

    getGlobalState: () => {
        const globalState = {};
        JurisDebugConsole.islands.forEach((island, name) => {
            globalState[name] = island.getState('any');
        });
        return globalState;
    },

    exportState: () => {
        return JSON.stringify(JurisDebugConsole.getGlobalState(), null, 2);
    },

    importState: (stateJson) => {
        try {
            const state = JSON.parse(stateJson);
            Object.keys(state).forEach(islandName => {
                const island = JurisDebugConsole.islands.get(islandName);
                if (island) {
                    Object.keys(state[islandName]).forEach(key => {
                        island.setState(key, state[islandName][key]);
                    });
                }
            });
            console.log('State imported successfully');
        } catch (error) {
            console.error('Failed to import state:', error);
        }
    }
};

// Register islands for debugging
JurisDebugConsole.register('todos', todosIsland);
JurisDebugConsole.register('login', loginIsland);

// Make debug console globally available
window.JurisDebug = JurisDebugConsole;

console.log('Juris Debug Console available as window.JurisDebug');
console.log('Commands: listIslands(), inspect(name), getGlobalState(), exportState()');
Enter fullscreen mode Exit fullscreen mode

Step 7: Complete Migration Example

7.1 Full Alpine.js Component

<div class="alpine-dashboard" x-data="{
    user: { name: 'John Doe', avatar: '/avatar.jpg' },
    notifications: [
        { id: 1, text: 'Welcome!', read: false },
        { id: 2, text: 'Update available', read: true }
    ],
    sidebarOpen: false,

    toggleSidebar() {
        this.sidebarOpen = !this.sidebarOpen;
    },

    markAsRead(id) {
        const notification = this.notifications.find(n => n.id === id);
        if (notification) {
            notification.read = true;
        }
    },

    get unreadCount() {
        return this.notifications.filter(n => !n.read).length;
    }
}">

    <!-- Header -->
    <header class="header">
        <button x-on:click="toggleSidebar()" x-text="sidebarOpen ? 'Close' : 'Menu'"></button>
        <div class="user-info">
            <img :src="user.avatar" :alt="user.name">
            <span x-text="user.name"></span>
        </div>
        <div class="notifications">
            <span x-text="unreadCount"></span> notifications
        </div>
    </header>

    <!-- Sidebar -->
    <aside x-show="sidebarOpen" x-transition class="sidebar">
        <nav>
            <a href="/dashboard">Dashboard</a>
            <a href="/profile">Profile</a>
            <a href="/settings">Settings</a>
        </nav>
    </aside>

    <!-- Notifications -->
    <div class="notifications-panel">
        <template x-for="notification in notifications" :key="notification.id">
            <div :class="{ 'unread': !notification.read }" 
                 x-on:click="markAsRead(notification.id)">
                <span x-text="notification.text"></span>
                <span x-show="!notification.read"></span>
            </div>
        </template>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

7.2 Equivalent Juris.js Islands

<div class="juris-dashboard">
    <!-- Header Island -->
    <header class="header-island">
        <button class="sidebar-toggle">Menu</button>
        <div class="user-info">
            <img class="user-avatar" src="/avatar.jpg" alt="User">
            <span class="user-name">John Doe</span>
        </div>
        <div class="notifications-badge">0 notifications</div>
    </header>

    <!-- Sidebar Island -->
    <aside class="sidebar-island" style="display: none;">
        <nav>
            <a href="/dashboard">Dashboard</a>
            <a href="/profile">Profile</a>
            <a href="/settings">Settings</a>
        </nav>
    </aside>

    <!-- Notifications Island -->
    <div class="notifications-island">
        <div class="notifications-list"></div>
    </div>
</div>

<script src="https://unpkg.com/juris@0.5.2/juris.js"></script>
<script>
// User Island
const userIsland = new Juris({
    states: {
        user: { name: 'John Doe', avatar: '/avatar.jpg' }
    }
});

userIsland.enhance('.user-name', {
    text: () => userIsland.getState('user.name')
});

userIsland.enhance('.user-avatar', {
    src: () => userIsland.getState('user.avatar'),
    alt: () => userIsland.getState('user.name')
});

// Sidebar Island
const sidebarIsland = new Juris({
    states: { open: false }
});

sidebarIsland.enhance('.sidebar-toggle', {
    text: () => sidebarIsland.getState('open') ? 'Close' : 'Menu',
    onclick: () => {
        const current = sidebarIsland.getState('open');
        sidebarIsland.setState('open', !current);
    }
});

sidebarIsland.enhance('.sidebar-island', {
    style: () => ({
        display: sidebarIsland.getState('open') ? 'block' : 'none',
        transition: 'all 0.3s ease'
    })
});

// Notifications Island
const notificationsIsland = new Juris({
    states: {
        notifications: [
            { id: 1, text: 'Welcome!', read: false },
            { id: 2, text: 'Update available', read: true }
        ]
    },
    services: {
        notificationManager: {
            markAsRead: (id) => {
                const notifications = notificationsIsland.getState('notifications');
                const updated = notifications.map(n => 
                    n.id === id ? { ...n, read: true } : n
                );
                notificationsIsland.setState('notifications', updated);
            },

            getUnreadCount: () => {
                const notifications = notificationsIsland.getState('notifications');
                return notifications.filter(n => !n.read).length;
            }
        }
    }
});

notificationsIsland.enhance('.notifications-badge', ({ notificationManager }) => ({
    text: () => `${notificationManager.getUnreadCount()} notifications`
}));

notificationsIsland.enhance('.notifications-list', ({ notificationManager }) => ({
    children: () => {
        const notifications = notificationsIsland.getState('notifications');
        return notifications.map(notification => ({
            div: {
                key: notification.id,
                className: notification.read ? 'notification read' : 'notification unread',
                onclick: () => notificationManager.markAsRead(notification.id),
                children: [
                    { span: { text: notification.text } },
                    notification.read ? null : { span: { text: '', className: 'unread-dot' } }
                ].filter(Boolean)
            }
        }));
    }
}));

// Debug all islands
window.dashboardDebug = {
    user: userIsland,
    sidebar: sidebarIsland,
    notifications: notificationsIsland,

    getAllStates: () => ({
        user: userIsland.getState('any'),
        sidebar: sidebarIsland.getState('any'),
        notifications: notificationsIsland.getState('any')
    }),

    simulateNotification: () => {
        const notifications = notificationsIsland.getState('notifications');
        const newNotification = {
            id: Date.now(),
            text: `New notification ${notifications.length + 1}`,
            read: false
        };
        notificationsIsland.setState('notifications', [...notifications, newNotification]);
    }
};

console.log('Dashboard debug available as window.dashboardDebug');
</script>
Enter fullscreen mode Exit fullscreen mode

Migration Benefits Summary

Debugging Advantages

  • Transparent State: island.getState('any') shows exact current state
  • State History: Track all state changes with timestamps
  • Live Inspection: Console access to all island states
  • Isolated Testing: Test each island independently
  • Performance Monitoring: Measure render times and state updates

Code Quality Improvements

  • Pure JavaScript: No template-bound logic
  • Service Injection: Clean dependency management
  • Type Safety: Easier to add TypeScript later
  • Testability: Standard JavaScript testing approaches
  • Maintainability: Clear separation of concerns

Development Experience

  • Better DevTools: Standard JavaScript debugging
  • Predictable Behavior: No hidden Alpine magic
  • Incremental Migration: Migrate one island at a time
  • Team Collaboration: Easier code reviews
  • Documentation: Self-documenting service architecture

Conclusion

Migrating from Alpine.js to Juris.js transforms your application from template-bound interactivity to pure, debuggable, islandable architecture. The key benefits:

  1. True Islands: Each enhancement is completely isolated
  2. Full Debugging: Transparent state management with console access
  3. Pure JavaScript: All logic in standard, debuggable JS functions
  4. Service Architecture: Clean dependency injection and separation of concerns
  5. Incremental Migration: Migrate one component at a time with confidence

The slightly larger bundle size (25KB vs 15KB) is offset by dramatically improved debugging capabilities, cleaner architecture, and better long-term maintainability. For teams prioritizing debuggability and code quality, Juris.js provides a compelling upgrade path from Alpine.js.

Comments 3 total

  • artydev
    artydevJun 26, 2025

    Long live to Juris ; #NORAV :-)

  • Nevo David
    Nevo DavidJun 26, 2025

    Pretty cool how clean the debugging gets here, honestly makes me wanna refactor my own mess instead of constantly poking random variables.

    • Pinoy Codie
      Pinoy CodieJun 26, 2025

      Thanks, and let me know of any feedback you may have

Add comment