When building workflow systems, one of the trickiest challenges is managing state and progression between steps. Many systems use complex state machines, event buses, or reactive frameworks. Pipulate takes a different approach: the Chain Reaction Pattern.
The Unix Philosophy in Web Apps
Remember Unix pipes? They’re beautiful in their simplicity:
cat data.txt | grep "pattern" | sort | uniq -c
Each command does one thing well, and data flows naturally through the pipeline. Pipulate’s Chain Reaction Pattern brings this same elegance to web workflows.
The Golden Pattern
At its core, the pattern is deceptively simple:
return Div(
Card(...), # Current step content
Div(
id=next_step_id,
hx_get=f"/{app_name}/{next_step_id}",
hx_trigger="load" # The magic happens here
),
id=step_id
)
That hx_trigger="load"
is Pipulate’s pipe operator. Just as removing a |
breaks a Unix pipeline, removing hx_trigger="load"
breaks the chain reaction.
Why It Works
The pattern succeeds because it:
- Maintains Atomicity: Each step is self-contained, like a Unix command
- Preserves State: State flows naturally between steps
- Enables Reversibility: Steps can be reverted without breaking the chain
- Simplifies Testing: Each step can be tested in isolation
- Follows HTMX Principles: Uses HTMX’s natural request/response cycle
Common Pitfalls
Breaking the Chain
❌ The most common mistake is breaking the chain reaction:
# WRONG: Missing hx_trigger="load"
return Div(
Card(...),
Div(id=next_step_id, hx_get=f"/{app_name}/{next_step_id}"),
id=step_id
)
✅ Always maintain the chain:
# RIGHT: Chain reaction preserved
return Div(
Card(...),
Div(id=next_step_id, hx_get=f"/{app_name}/{next_step_id}", hx_trigger="load"),
id=step_id
)
Error Handling
❌ Another common mistake is forgetting the chain in error paths:
# WRONG: Chain broken on error
if error:
return P("Error message", style=pip.get_style("error"))
✅ Maintain the chain even in error paths:
# RIGHT: Chain preserved in error handling
if error:
return Div(
P("Error message", style=pip.get_style("error")),
Div(id=next_step_id, hx_get=next_url, hx_trigger="load"),
id=step_id
)
Special Cases
Long-Running Operations
For operations that take time, use background processing while maintaining the chain:
# Submit handler starts the process
return Card(
Progress(...),
Script("""
setTimeout(function() {
htmx.ajax('POST', '""" + f"/{app_name}/process" + """', {
target: '#""" + step_id + """'
});
}, 500);
"""),
id=step_id
)
# Process handler maintains the chain
return Div(
Card(
H3("Processing Complete"),
results
),
Div(id=next_step_id, hx_get=next_url, hx_trigger="load"),
id=step_id
)
File Downloads
File downloads require special handling to maintain the chain:
return Div(
A(
"Download File",
href=download_url,
download=True,
_onclick="setTimeout(() => htmx.trigger('#continue-btn', 'click'), 1000);"
),
Button(
"Continue",
id="continue-btn",
style="display: none;",
hx_get=f"/{app_name}/{next_step_id}",
hx_target=f"#{step_id}"
),
id=step_id
)
The WET Philosophy
Pipulate intentionally follows the WET (Write Everything Twice) principle rather than DRY (Don’t Repeat Yourself). Why? Because workflows often need subtle variations that DRY abstractions would make harder to implement.
This is similar to how Unix commands have overlapping functionality (cat
vs less
, find
vs locate
). The overlap gives users flexibility without sacrificing reliability.
Testing Chain Reactions
Always test these scenarios:
- Normal progression through steps
- Error handling in each step
- Background processing completion
- File download handling
- State preservation between steps
- Revert functionality
- Edge cases specific to your workflow
Real-World Example
Here’s a complete step implementation showing all the patterns:
async def step_XX(self, request):
"""GET handler for step XX display"""
pip, db, steps, app_name = self.pipulate, self.db, self.steps, self.app_name
step_id = "step_XX"
step_index = self.steps_indices[step_id]
step = steps[step_index]
next_step_id = steps[step_index + 1].id if step_index < len(steps) - 1 else 'finalize'
pipeline_id = db.get("pipeline_id", "unknown")
state = pip.read_state(pipeline_id)
try:
# Process step
result = await process_data()
# Store state
await pip.update_step_state(pipeline_id, step_id, result, steps)
# Return with chain reaction
return Div(
Card(
H3("Processing Complete"),
result_display
),
Div(id=next_step_id, hx_get=f"/{app_name}/{next_step_id}", hx_trigger="load"),
id=step_id
)
except Exception as e:
# Error handling with chain reaction
return Div(
P(f"Error: {str(e)}", style=pip.get_style("error")),
Div(id=next_step_id, hx_get=f"/{app_name}/{next_step_id}", hx_trigger="load"),
id=step_id
)
Conclusion
The Chain Reaction Pattern is a powerful example of how simple patterns can create robust systems. By following Unix philosophy and embracing HTMX’s natural patterns, Pipulate creates workflows that are both powerful and maintainable.
Remember:
- Never break the chain
- Keep steps atomic
- Maintain state properly
- Handle errors gracefully
- Test thoroughly
Just as Unix pipes changed how we think about command-line tools, the Chain Reaction Pattern changes how we think about web workflows.