Skip to content

Backend Integration#

The real power of Jac's full-stack model is how seamlessly client components connect to server logic. Instead of manually defining REST endpoints, writing fetch calls, and parsing JSON, you simply call server-side functions or spawn walkers from your client code -- the compiler generates the HTTP layer automatically. Data goes from your graph through walkers, over the network, and into your React-style components with minimal boilerplate.

This tutorial shows how to call backend functions from the frontend, use walkers for graph-based data operations, handle loading states, and structure your server-client communication.

Prerequisites

Walker spawn results and jac check

Snippets that read result.reports from root spawn MyWalker() runs work at runtime, but the static checker has no typed shape for the walker spawn-result yet. Many blocks below also call sv import from ...main { ... } for cross-module walkers; until those server-export stubs land, isolated jac check runs on these excerpts surface E1032 warnings.


How It Works#

In Jac full-stack apps, the compiler handles the client-server boundary for you. Here's the mental model:

  1. Backend = Functions or walkers that process data and return results
  2. Frontend = Components in cl { } blocks (or .cl.jac files)
  3. Connection = Direct function calls (await func()) or walker spawning (root spawn Walker())
graph LR
    Frontend["Frontend<br/>Component"] <-- "HTTP<br/>function call / root spawn" --> Backend["Functions or<br/>Walker API"]

Two Backend Approaches#

Jac offers two ways to create backend endpoints:

Approach Syntax Best For
Functions def:pub get_tasks -> list[Task] { ... } Simple CRUD, quick prototyping
Walkers walker:pub get_tasks { can fetch with Root entry { ... } } Graph traversal, multi-step operations

Both become HTTP endpoints automatically. Functions are simpler -- the frontend calls them directly with await func(). Walkers are more powerful -- they traverse the graph and report results, called with root spawn Walker().

Use def:priv / walker:priv for authenticated endpoints with per-user data isolation. See the AI Day Planner tutorial for both approaches side by side.


Setting Up the Backend#

Define Your Data Model#

node Task {
    has title: str;
    has created_at: str;
    has completed: bool = False;
}

Every node automatically gets a unique identifier from the runtime, accessible via jid(node). You never need to manage IDs manually.

Create Walker Endpoints#

import from datetime { datetime }

walker:pub get_tasks {
    can fetch with Root entry {
        report [-->][?:Task];  # Reports typed Task nodes directly
    }
}

walker:pub add_task {
    has title: str;

    can create with Root entry {
        new_task = Task(
            title=self.title,
            created_at=datetime.now().isoformat()
        );
        root ++> new_task;
        report new_task;  # Report the typed Task node
    }
}

walker:pub toggle_task {
    has task_id: str;

    can toggle with Root entry {
        for task in [-->][?:Task] {
            if jid(task) == self.task_id {
                task.completed = not task.completed;
                report task;  # Report the updated Task node
                return;
            }
        }
    }
}

walker:pub delete_task {
    has task_id: str;

    can remove with Root entry {
        for task in [-->][?:Task] {
            if jid(task) == self.task_id {
                del task;
                report {"success": True};
                return;
            }
        }
    }
}

Calling Walkers from Frontend#

Basic Pattern with root spawn#

Use root spawn walker_name() to call walkers from client code:

# Import walkers from your main module
sv import from ...main { get_tasks, add_task, toggle_task }

cl {
    def:pub TaskList() -> JsxElement {
        has tasks: list = [];
        has loading: bool = True;
        has error: str = "";

        # Fetch data on component mount
        async can with entry {
            await load_tasks();
        }

        async def load_tasks() -> None {
            loading = True;
            error = "";
            try {
                result = root spawn get_tasks();
                if result.reports and result.reports.length > 0 {
                    tasks = result.reports[0];
                }
            } except Exception as e {
                error = f"Failed to load: {e}";
            }
            loading = False;
        }

        if loading {
            return <p>Loading tasks...</p>;
        }

        if error {
            return <p className="error">Error: {error}</p>;
        }

        return <ul>
            {[<li key={jid(task)}>{task.title}</li> for task in tasks]}
        </ul>;
    }
}

Understanding root spawn Results#

Property Type Description
result.reports list Array of reported values from walker
result.reports[0] any First reported value (most common)

Mutations (Create, Update, Delete)#

sv import from ...main { get_tasks, add_task, toggle_task, delete_task }

cl {
    def:pub TaskManager() -> JsxElement {
        has tasks: list = [];
        has new_title: str = "";
        has loading: bool = True;

        # Load tasks on mount
        async can with entry {
            await load_tasks();
        }

        async def load_tasks() -> None {
            loading = True;
            result = root spawn get_tasks();
            if result.reports and result.reports.length > 0 {
                tasks = result.reports[0];
            }
            loading = False;
        }

        # Add new task
        async def handle_add() -> None {
            if new_title.trim() {
                result = root spawn add_task(title=new_title.trim());
                if result.reports and result.reports.length > 0 {
                    tasks = tasks + [result.reports[0]];
                }
                new_title = "";
            }
        }

        # Toggle task completion
        async def handle_toggle(task_id: str) -> None {
            result = root spawn toggle_task(task_id=task_id);
            if result.reports and result.reports.length > 0 {
                updated = result.reports[0];
                tasks = [updated if jid(t) == task_id else t for t in tasks];
            }
        }

        # Delete task
        async def handle_delete(task_id: str) -> None {
            result = root spawn delete_task(task_id=task_id);
            if result.reports and result.reports.length > 0 {
                tasks = [t for t in tasks if jid(t) != task_id];
            }
        }

        return <div>
            <div className="add-task">
                <input
                    value={new_title}
                    onChange={lambda e: ChangeEvent { new_title = e.target.value; }}
                    onKeyDown={lambda e: KeyboardEvent {
                        if e.key == "Enter" { handle_add(); }
                    }}
                    placeholder="New task..."
                />
                <button onClick={lambda -> None { handle_add(); }}>
                    Add
                </button>
            </div>

            {loading and <p>Loading...</p>}

            <ul className="task-list">
                {[
                    <li key={jid(task)}>
                        <input
                            type="checkbox"
                            checked={task.completed}
                            onChange={lambda -> None { handle_toggle(jid(task)); }}
                        />
                        <span className={task.completed and "completed"}>
                            {task.title}
                        </span>
                        <button onClick={lambda -> None { handle_delete(jid(task)); }}>
                            Delete
                        </button>
                    </li>
                    for task in tasks
                ]}
            </ul>
        </div>;
    }
}

Error Handling#

Try-Catch Pattern#

sv import from ...main { submit_data }

cl {
    def:pub SafeSubmit() -> JsxElement {
        has error_msg: str = "";
        has submitting: bool = False;

        async def handle_submit(data: dict) -> None {
            submitting = True;
            error_msg = "";

            try {
                result = root spawn submit_data(payload=data);
                if result.reports and result.reports.length > 0 {
                    response = result.reports[0];
                    if not response["success"] {
                        error_msg = response["error"];
                    }
                }
            } except Exception as e {
                error_msg = f"Network error: {e}";
            }

            submitting = False;
        }

        return <div>
            {error_msg and <div className="error">{error_msg}</div>}
            <button
                onClick={lambda -> None { handle_submit({"key": "value"}); }}
                disabled={submitting}
            >
                {("Submitting..." if submitting else "Submit")}
            </button>
        </div>;
    }
}

Loading States Pattern#

cl {
    def:pub DataView() -> JsxElement {
        has data: any = None;
        has loading: bool = True;
        has error: str = "";

        async can with entry {
            await fetch_data();
        }

        async def fetch_data() -> None {
            loading = True;
            try {
                result = root spawn get_data();
                if result.reports and result.reports.length > 0 {
                    data = result.reports[0];
                }
            } except Exception as e {
                error = f"Failed to load: {e}";
            }
            loading = False;
        }

        if loading {
            return <div className="skeleton">
                <div className="skeleton-line"></div>
                <div className="skeleton-line"></div>
            </div>;
        }

        if error {
            return <div className="error">
                <p>{error}</p>
                <button onClick={lambda -> None { fetch_data(); }}>
                    Retry
                </button>
            </div>;
        }

        return <div className="data">
            {JSON.stringify(data)}
        </div>;
    }
}

Real-Time Updates#

Polling Pattern#

cl {
    import from react { useEffect }

    def:pub LiveData() -> JsxElement {
        has data: any = None;
        has loading: bool = True;

        async def fetch_data() -> None {
            result = root spawn get_live_data();
            if result.reports and result.reports.length > 0 {
                data = result.reports[0];
            }
            loading = False;
        }

        # Initial fetch
        async can with entry {
            await fetch_data();
        }

        # Poll every 5 seconds. The outer lambda must NOT be annotated `-> None`:
        # a cleanup effect returns a function, so `-> None` would be a type error.
        useEffect(lambda {
            interval = setInterval(lambda { fetch_data(); }, 5000);
            return lambda { clearInterval(interval); };
        }, []);

        return <div>
            {loading and <p>Loading...</p>}
            {data and <p>Last updated: {data["timestamp"]}</p>}
        </div>;
    }
}

Complete Example: Task App#

# main.jac - Combined backend and frontend

# === Backend: Data Model ===
node Task {
    has title: str;
    has completed: bool = False;
}

# === Backend: Walkers ===
walker:pub get_tasks {
    can fetch with Root entry {
        report [-->][?:Task];
    }
}

walker:pub add_task {
    has title: str;

    can create with Root entry {
        task = Task(title=self.title);
        root ++> task;
        report task;
    }
}

walker:pub toggle_task {
    has task_id: str;

    can toggle with Root entry {
        for t in [-->][?:Task] {
            if jid(t) == self.task_id {
                t.completed = not t.completed;
                report t;
                return;
            }
        }
    }
}

# === Frontend: UI ===
cl {
    def:pub app() -> JsxElement {
        has tasks: list = [];
        has input_text: str = "";
        has loading: bool = True;

        # Load tasks on mount
        async can with entry {
            result = root spawn get_tasks();
            if result.reports {
                tasks = result.reports;
            }
            loading = False;
        }

        async def add() -> None {
            if input_text.trim() {
                result = root spawn add_task(title=input_text.trim());
                if result.reports and result.reports.length > 0 {
                    tasks = tasks + [result.reports[0]];
                }
                input_text = "";
            }
        }

        async def toggle(task_id: str) -> None {
            result = root spawn toggle_task(task_id=task_id);
            if result.reports and result.reports.length > 0 {
                updated = result.reports[0];
                tasks = [updated if jid(t) == task_id else t for t in tasks];
            }
        }

        return <div className="app">
            <h1>My Tasks</h1>

            <div className="input-row">
                <input
                    value={input_text}
                    onChange={lambda e: ChangeEvent { input_text = e.target.value; }}
                    onKeyDown={lambda e: KeyboardEvent {
                        if e.key == "Enter" { add(); }
                    }}
                    placeholder="Add a task..."
                />
                <button onClick={lambda -> None { add(); }}>Add</button>
            </div>

            {loading and <p>Loading...</p>}

            {not loading and (
                <ul>
                    {[
                        <li
                            key={jid(t)}
                            style={{"textDecoration": t.completed and "line-through"}}
                            onClick={lambda -> None { toggle(jid(t)); }}
                        >
                            {t.title}
                        </li>
                        for t in tasks
                    ]}
                </ul>
            )}
        </div>;
    }
}

Key Takeaways#

Concept Usage
Import walkers sv import from ...main { walker_name }
Call walker result = root spawn walker_name(args)
Get results result.reports[0]
Node spawn node_id spawn walker_name(args)
Error handling try { ... } except Exception as e { ... }

Next Steps#

Reference: