Skip to content

Add x-else and x-else-if Directives for Conditional Rendering#4353

Open
mvaliolahi wants to merge 1 commit intoalpinejs:mainfrom
mvaliolahi:feature/else
Open

Add x-else and x-else-if Directives for Conditional Rendering#4353
mvaliolahi wants to merge 1 commit intoalpinejs:mainfrom
mvaliolahi:feature/else

Conversation

@mvaliolahi
Copy link

@mvaliolahi mvaliolahi commented Aug 27, 2024

This pull request adds two new directives, x-else and x-else-if, to Alpine.js.

How It Works:

x-else-if: Looks for a preceding x-if or x-else-if and only shows its content if none of those conditions are true.
x-else: Displays its content only if all previous conditions have failed.

Basic Usage:

<div x-data="{ value: 2 }">
    <template x-if="value === 1">
        <h1>Value is 1</h1>
    </template>
    <template x-else-if="value === 2">
        <h2>Value is 2</h2>
    </template>
    <template x-else-if="value === 3">
        <h3>Value is 3</h3>
    </template>
    <template x-else>
        <h4>Value is unknown</h4>
    </template>
</div>

Inside a Loop:

<div x-data="{ items: [{id: 1, value: 1}, {id: 2, value: 2}, {id: 3, value: 3}, {id: 4, value: 4}] }">
    <template x-for="item in items" :key="item.id">
        <div>
            <template x-if="item.value === 1">
                <span>Item <span x-text="item.id"></span>: Value is 1</span>
            </template>
            <template x-else-if="item.value === 2">
                <span>Item <span x-text="item.id"></span>: Value is 2</span>
            </template>
            <template x-else-if="item.value === 3">
                <span>Item <span x-text="item.id"></span>: Value is 3</span>
            </template>
            <template x-else>
                <span>Item <span x-text="item.id"></span>: Value is unknown</span>
            </template>
        </div>
    </template>
</div>

Tests:

I've added tests to ensure these new directives work as expected, including cases with multiple conditions and inside x-for loops.

Why This Matters:

This enhancement provides more flexibility for conditional logic, allowing you to handle complex scenarios directly within your templates.

@ekwoka
Copy link
Contributor

ekwoka commented Aug 27, 2024

There's some cases not covered in the code and tests

mvaliolahi#1

Made a PR to add these tests.

Specifically:

  • What if there are multiple else-if that overlap?
  • What if there are else-if that have different dependencies?

This is a great feature to have, so kudos for stepping up to do it!

I don't think this isn't a great way to go about it though.

Maybe have the initial x-if walk forward through the DOM to identify the branches and control who should show.

@mvaliolahi
Copy link
Author

@ekwoka I think your concerns are valid I will change the code to support this case.

@Lettever
Copy link

Any updates for this pr? This would be very useful @ekwoka, thanks in advance

@ekwoka
Copy link
Contributor

ekwoka commented Nov 29, 2024

I'm not the maintainer, so I can't approve or reject PRs.

@calebporzio
Copy link
Collaborator

PR Review: #4353 — Add x-else and x-else-if Directives for Conditional Rendering

Type: Feature
Verdict: Needs discussion

What's happening (plain English)

This PR adds two new directives — x-else-if and x-else — to create if/else-if/else chains in Alpine templates, similar to how Vue handles conditional rendering:

<template x-if="value === 1"><h1>One</h1></template>
<template x-else-if="value === 2"><h2>Two</h2></template>
<template x-else><h3>Other</h3></template>

Currently each directive is its own independent Alpine directive with its own effect(). They communicate via DOM properties (_x_ifSatisfied, _x_elseIfSatisfied) to know whether a preceding condition was true.

Architectural problems

1. x-else-if doesn't propagate "satisfied" state when hiding due to a prior match.

In x-else-if.js, when a preceding condition is already satisfied, the code hides but never sets _x_elseIfSatisfied:

if (prevConditionsSatisfied) {
    hide()
    // _x_elseIfSatisfied is never set here!
} else if (value) {
    show()
    el._x_elseIfSatisfied = true  // only set in this branch
} else {
    hide()
    el._x_elseIfSatisfied = false
}

This means in a chain like x-if → x-else-if-A → x-else-if-B, if x-if is true, x-else-if-A hides but doesn't set _x_elseIfSatisfied. Then x-else-if-B checks prevElement._x_elseIfSatisfied which is undefined (falsy), so it falls through to evaluate its own condition — and may incorrectly show itself.

The existing tests don't catch this because they don't test a chain of 3+ x-else-if where the first condition is true and a later x-else-if also evaluates to true.

2. x-else has a cross-chain contamination bug.

x-else walks backward through ALL preceding siblings collecting conditions, but never stops at a chain boundary. If you have two independent if/else chains in the same parent:

<template x-if="a">...</template>
<template x-else>...</template>
<template x-if="b">...</template>
<template x-else>...</template>  <!-- BUG: also evaluates condition "a" -->

The second x-else would collect condition a from the first chain, meaning it would hide when a OR b is true, instead of just when b is true.

3. x-else-if and x-else use fundamentally different strategies.

x-else-if reads _x_ifSatisfied/_x_elseIfSatisfied flags (set by other effects). x-else re-evaluates ALL preceding expressions from scratch via evaluateLater. These are two different reactivity models for the same feature chain. This inconsistency is a maintenance red flag.

4. Independent effects rely on execution order.

Each directive in the chain has its own effect(). The correctness of x-else-if depends on x-if's effect running first (to set _x_ifSatisfied before x-else-if reads it). This works today because of directive priority ordering, but it's fragile — any refactor to Alpine's effect scheduling could break it silently.

Other approaches considered

  1. Have x-if own the entire chain (recommended). When x-if initializes, it walks forward through siblings to find the complete chain of x-else-if/x-else templates. A single effect() evaluates conditions in order and shows/hides the appropriate branch. This eliminates timing issues, cross-chain bugs, and the need for inter-element flags. This is what @ekwoka suggested in the comments.

  2. Event-based communication between directives. Each directive could dispatch/listen for custom events to coordinate. Overly complex for this use case.

  3. Current approach (each directive independent, communicate via DOM flags). Works for simple cases but breaks on chains of 3+ and has the cross-chain bug. Not recommended.

Changes Made

No code changes made — the issues are architectural and need a different approach.

Test Results

All existing tests pass:

  • x-else.spec.js: 7/7 passing
  • x-else-if.spec.js: 3/3 passing
  • x-if.spec.js: 6/6 passing (no regressions)

However, the tests don't cover the failure cases identified above (3+ element chains where first condition is true, multiple independent chains in the same parent).

Code Review

Style issues (minor, would fix if merging):

  • packages/alpinejs/src/directives/index.js:20-21: Semicolons on import lines (Alpine doesn't use semicolons)
  • x-else.js imports evaluateLater but also uses it in a non-standard way (evaluating expressions against elements that aren't the directive's own element)
  • packages/docs/package.json: Version bump is unrelated noise

The x-else unused import: x-else.js:1 imports evaluateLater from '../evaluator' and uses it to re-evaluate other elements' expressions from a different element's context. This is unusual for Alpine and could cause scope resolution issues.

Security

No security concerns identified. The directives use the standard evaluateLater API for expression evaluation.

Verdict

Needs discussion. The feature itself is worthwhile — x-else/x-else-if is a natural addition to Alpine and has community demand (3 thumbs-up, multiple comments asking about it). But the implementation architecture has real bugs (chain propagation, cross-chain contamination) and the dual-strategy approach (flags vs re-evaluation) is fragile.

The right path forward is approach #1: have x-if discover and own the full chain. This would be a single directive enhancement rather than two new directives, which is simpler and more aligned with Alpine's philosophy. Happy to implement this if you want to go that direction, Caleb.


Reviewed by Claude

Fix else-if propagation and x-else chain boundary
Address PR alpinejs#4353 review: else-if propagation, else chain boundary, tests
@mvaliolahi
Copy link
Author

mvaliolahi commented Feb 9, 2026

Addressed the review:
x-else-if: Set _x_elseIfSatisfied = true when hiding due to a prior match (fixes 3+ chain).
x-else: Stop at previous x-else when collecting conditions so two chains don’t cross-contaminate.
Removed semicolons from new directive imports; added tests for overlapping else-if and two independent chains.
Relevant Cypress specs pass.
@calebporzio

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants