Build Your First Praxis App
December 4, 2025 ยท View on GitHub
This tutorial walks you through building a simple counter application with Praxis. You'll learn the core concepts: facts, events, rules, and how to connect logic to a UI.
Time: 15-20 minutes
Level: Beginner
Prerequisites: Node.js 18+, basic TypeScript
What You'll Build
A counter application that:
- Displays a count value
- Has increment and decrement buttons
- Shows a history of actions
- Demonstrates undo/redo
Step 1: Create the Project
# Create a new directory
mkdir praxis-counter
cd praxis-counter
# Initialize npm
npm init -y
# Install Praxis
npm install @plures/praxis
# Install development tools
npm install -D typescript @types/node vitest
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
Step 2: Define the Schema
Create src/schema.psf.json:
{
"$version": "1.0.0",
"id": "counter-app",
"name": "Counter",
"description": "A simple counter application",
"facts": [
{
"id": "fact_incremented",
"tag": "Incremented",
"description": "Counter was incremented",
"payload": {
"type": "object",
"properties": {
"previousValue": { "type": "number" },
"newValue": { "type": "number" }
}
}
},
{
"id": "fact_decremented",
"tag": "Decremented",
"description": "Counter was decremented",
"payload": {
"type": "object",
"properties": {
"previousValue": { "type": "number" },
"newValue": { "type": "number" }
}
}
},
{
"id": "fact_reset",
"tag": "Reset",
"description": "Counter was reset to zero",
"payload": {
"type": "object",
"properties": {
"previousValue": { "type": "number" }
}
}
}
],
"events": [
{
"id": "event_increment",
"tag": "INCREMENT",
"description": "Increment the counter",
"payload": { "type": "object", "properties": {} }
},
{
"id": "event_decrement",
"tag": "DECREMENT",
"description": "Decrement the counter",
"payload": { "type": "object", "properties": {} }
},
{
"id": "event_reset",
"tag": "RESET",
"description": "Reset counter to zero",
"payload": { "type": "object", "properties": {} }
}
],
"rules": [
{
"id": "counter.increment",
"name": "Increment Counter",
"description": "Increment count when INCREMENT event occurs",
"triggers": ["INCREMENT"],
"priority": 10
},
{
"id": "counter.decrement",
"name": "Decrement Counter",
"description": "Decrement count when DECREMENT event occurs",
"triggers": ["DECREMENT"],
"priority": 10
},
{
"id": "counter.reset",
"name": "Reset Counter",
"description": "Reset count to zero when RESET event occurs",
"triggers": ["RESET"],
"priority": 10
}
],
"constraints": [
{
"id": "counter.nonNegative",
"name": "Non-Negative Count",
"description": "Count must not be negative",
"errorMessage": "Counter cannot go below zero",
"severity": "error"
}
]
}
Step 3: Create the Engine
Create src/engine.ts:
import {
createPraxisEngine,
PraxisRegistry,
defineFact,
defineEvent,
defineRule,
defineConstraint,
} from '@plures/praxis';
// Define the context (application state)
interface CounterContext {
count: number;
history: string[];
}
// Define Facts
export const Incremented = defineFact<'Incremented', { previousValue: number; newValue: number }>(
'Incremented'
);
export const Decremented = defineFact<'Decremented', { previousValue: number; newValue: number }>(
'Decremented'
);
export const Reset = defineFact<'Reset', { previousValue: number }>('Reset');
// Define Events
export const INCREMENT = defineEvent<'INCREMENT', {}>('INCREMENT');
export const DECREMENT = defineEvent<'DECREMENT', {}>('DECREMENT');
export const RESET = defineEvent<'RESET', {}>('RESET');
// Define Rules
const incrementRule = defineRule<CounterContext>({
id: 'counter.increment',
description: 'Increment count when INCREMENT event occurs',
impl: (state, events) => {
const event = events.find(INCREMENT.is);
if (!event) return [];
const previousValue = state.context.count;
const newValue = previousValue + 1;
// Update state
state.context.count = newValue;
state.context.history.push(`Incremented from ${previousValue} to ${newValue}`);
// Emit fact
return [Incremented.create({ previousValue, newValue })];
},
});
const decrementRule = defineRule<CounterContext>({
id: 'counter.decrement',
description: 'Decrement count when DECREMENT event occurs',
impl: (state, events) => {
const event = events.find(DECREMENT.is);
if (!event) return [];
const previousValue = state.context.count;
const newValue = previousValue - 1;
// Update state
state.context.count = newValue;
state.context.history.push(`Decremented from ${previousValue} to ${newValue}`);
// Emit fact
return [Decremented.create({ previousValue, newValue })];
},
});
const resetRule = defineRule<CounterContext>({
id: 'counter.reset',
description: 'Reset count to zero when RESET event occurs',
impl: (state, events) => {
const event = events.find(RESET.is);
if (!event) return [];
const previousValue = state.context.count;
// Update state
state.context.count = 0;
state.context.history.push(`Reset from ${previousValue} to 0`);
// Emit fact
return [Reset.create({ previousValue })];
},
});
// Define Constraint
const nonNegativeConstraint = defineConstraint<CounterContext>({
id: 'counter.nonNegative',
description: 'Count must not be negative',
check: (state) => state.context.count >= 0,
errorMessage: 'Counter cannot go below zero',
severity: 'error',
});
// Create Registry
const registry = new PraxisRegistry<CounterContext>();
registry.registerRule(incrementRule);
registry.registerRule(decrementRule);
registry.registerRule(resetRule);
registry.registerConstraint(nonNegativeConstraint);
// Create and export the engine factory
export function createCounterEngine() {
return createPraxisEngine({
initialContext: {
count: 0,
history: [],
},
registry,
});
}
Step 4: Test the Engine
Create src/main.ts:
import { createCounterEngine, INCREMENT, DECREMENT, RESET } from './engine';
// Create the engine
const engine = createCounterEngine();
console.log('Initial state:', engine.getContext());
// { count: 0, history: [] }
// Increment a few times
engine.dispatch([INCREMENT.create({})]);
console.log('After increment:', engine.getContext());
// { count: 1, history: ['Incremented from 0 to 1'] }
engine.dispatch([INCREMENT.create({})]);
engine.dispatch([INCREMENT.create({})]);
console.log('After more increments:', engine.getContext());
// { count: 3, history: [...] }
// Decrement
engine.dispatch([DECREMENT.create({})]);
console.log('After decrement:', engine.getContext());
// { count: 2, history: [...] }
// Try to go negative (should be blocked by constraint)
engine.dispatch([DECREMENT.create({})]);
engine.dispatch([DECREMENT.create({})]);
const result = engine.step([DECREMENT.create({})]);
console.log('Trying to go negative, violations:', result.violations);
// [{ id: 'counter.nonNegative', message: 'Counter cannot go below zero' }]
// Reset
engine.dispatch([RESET.create({})]);
console.log('After reset:', engine.getContext());
// { count: 0, history: [..., 'Reset from 2 to 0'] }
// Show history
console.log('\nAction history:');
engine.getContext().history.forEach((entry, i) => {
console.log(`${i + 1}. ${entry}`);
});
Run it:
npx tsx src/main.ts
Step 5: Add History (Undo/Redo)
Update src/main.ts to demonstrate history:
import { createPraxisEngine, PraxisRegistry } from '@plures/praxis';
import { createCounterEngine, INCREMENT, DECREMENT, RESET } from './engine';
// Create engine with history enabled
const engine = createPraxisEngine({
initialContext: {
count: 0,
history: [],
},
registry: new PraxisRegistry(), // You'd use your real registry here
enableHistory: true,
maxHistorySize: 10,
});
// For this demo, let's use the simpler approach
const counterEngine = createCounterEngine();
console.log('=== Counter with Actions ===\n');
// Track all actions
const actions: string[] = [];
function logState(action: string) {
actions.push(action);
const ctx = counterEngine.getContext();
console.log(`${action}: count = ${ctx.count}`);
}
logState('Initial');
counterEngine.dispatch([INCREMENT.create({})]);
logState('Increment');
counterEngine.dispatch([INCREMENT.create({})]);
logState('Increment');
counterEngine.dispatch([INCREMENT.create({})]);
logState('Increment');
counterEngine.dispatch([DECREMENT.create({})]);
logState('Decrement');
counterEngine.dispatch([RESET.create({})]);
logState('Reset');
console.log('\n=== Complete History ===');
counterEngine.getContext().history.forEach((entry, i) => {
console.log(` ${i + 1}. ${entry}`);
});
Step 6: Write Tests
Create src/engine.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import {
createCounterEngine,
INCREMENT,
DECREMENT,
RESET,
Incremented,
Decremented,
Reset,
} from './engine';
describe('Counter Engine', () => {
let engine: ReturnType<typeof createCounterEngine>;
beforeEach(() => {
engine = createCounterEngine();
});
describe('INCREMENT', () => {
it('should increment the count', () => {
engine.dispatch([INCREMENT.create({})]);
expect(engine.getContext().count).toBe(1);
});
it('should emit Incremented fact', () => {
const result = engine.step([INCREMENT.create({})]);
expect(result.state.facts).toContainEqual(expect.objectContaining({ tag: 'Incremented' }));
});
it('should record in history', () => {
engine.dispatch([INCREMENT.create({})]);
expect(engine.getContext().history).toContain('Incremented from 0 to 1');
});
});
describe('DECREMENT', () => {
it('should decrement the count', () => {
engine.dispatch([INCREMENT.create({})]);
engine.dispatch([INCREMENT.create({})]);
engine.dispatch([DECREMENT.create({})]);
expect(engine.getContext().count).toBe(1);
});
it('should emit Decremented fact', () => {
engine.dispatch([INCREMENT.create({})]);
const result = engine.step([DECREMENT.create({})]);
expect(result.state.facts).toContainEqual(expect.objectContaining({ tag: 'Decremented' }));
});
});
describe('RESET', () => {
it('should reset count to zero', () => {
engine.dispatch([INCREMENT.create({})]);
engine.dispatch([INCREMENT.create({})]);
engine.dispatch([INCREMENT.create({})]);
engine.dispatch([RESET.create({})]);
expect(engine.getContext().count).toBe(0);
});
});
describe('Constraints', () => {
it('should report violation when going negative', () => {
const result = engine.step([DECREMENT.create({})]);
expect(result.violations.length).toBeGreaterThan(0);
expect(result.violations[0].message).toContain('cannot go below zero');
});
});
describe('Multiple events', () => {
it('should handle multiple events at once', () => {
engine.dispatch([INCREMENT.create({}), INCREMENT.create({}), INCREMENT.create({})]);
expect(engine.getContext().count).toBe(3);
});
});
});
Add test script to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Run tests:
npm test
What You Learned
In this tutorial, you learned:
- Project Setup: How to create a Praxis project from scratch
- PSF Schema: The structure of a Praxis Schema Format document
- Facts: Typed propositions about what happened
- Events: Actions that drive state changes
- Rules: Pure functions that process events and emit facts
- Constraints: Invariants that must hold true
- Registry: How to register rules and constraints
- Engine: How to create and use a Praxis engine
- Testing: How to test your logic
Next Steps
- Todo with PluresDB - Add data persistence
- Svelte Integration - Connect to a UI
- Examples - See more complex applications
Complete Source Code
The complete source code for this tutorial is available at: examples/counter
Next Tutorial: Todo with PluresDB