State Management#
Interactive applications need to track and respond to changing data -- a counter incrementing, a list of items growing, a form being filled out. In Jac's client-side code, state management uses the has keyword inside component functions to declare reactive variables. When a has variable changes, the component automatically re-renders to reflect the new value, just like React's useState hook.
This tutorial covers declaring reactive state, handling user input, sharing state between components, and managing complex state with effects and derived values.
Prerequisites
- Completed: React-Style Components
- Time: ~30 minutes
Reactive state, hooks, and jac check
Some blocks below mix reactive has state with React-flavored helpers (createContext, useContext, async effect blocks). Jac's bundler wires all of this up at build time, but the static checker has not yet shipped typed stubs for every React import, so isolated jac check runs flag the corresponding accesses as Unknown. The patterns work as written under jac start.
Reactive State with has#
Inside client-side code (a .cl.jac file or a cl { } block), has creates reactive state (like React's useState). Declaring has count: int = 0; inside a component function creates a stateful variable that persists across re-renders and triggers a UI update whenever its value changes:
cl {
def:pub Counter() -> JsxElement {
has count: int = 0; # Reactive state
return <div>
<p>Count: {count}</p>
<button onClick={lambda -> None { count = count + 1; }}>
Increment
</button>
</div>;
}
}
How it works:
has count: int = 0compiles toconst [count, setCount] = useState(0)- Assignments like
count = count + 1becomesetCount(count + 1) - The component re-renders when state changes
Multiple State Variables#
cl {
def:pub Form() -> JsxElement {
has name: str = "";
has email: str = "";
has submitted: bool = False;
def handle_submit() -> None {
print(f"Submitting: {name}, {email}");
submitted = True;
}
if submitted {
return <p>Thanks, {name}!</p>;
}
return <form>
<input
value={name}
onChange={lambda e: ChangeEvent { name = e.target.value; }}
placeholder="Name"
/>
<input
value={email}
onChange={lambda e: ChangeEvent { email = e.target.value; }}
placeholder="Email"
/>
<button
type="button"
onClick={lambda -> None { handle_submit(); }}
>
Submit
</button>
</form>;
}
}
Complex State (Objects/Lists)#
cl {
def:pub TodoApp() -> JsxElement {
has todos: list = [];
has input_text: str = "";
def add_todo() -> None {
if input_text {
todos = todos + [{"id": len(todos), "text": input_text}];
input_text = "";
}
}
def remove_todo(id: int) -> None {
todos = [t for t in todos if t["id"] != id];
}
return <div>
<input
value={input_text}
onChange={lambda e: ChangeEvent { input_text = e.target.value; }}
/>
<button onClick={lambda -> None { add_todo(); }}>Add</button>
<ul>
{[
<li key={todo["id"]}>
{todo["text"]}
<button onClick={lambda -> None { remove_todo(todo["id"]); }}>
X
</button>
</li>
for todo in todos
]}
</ul>
</div>;
}
}
Important: For lists and objects, create new references:
todos = [...todos, newItem](spread to new list)todos = [t for t in todos if condition](filter to new list)
useEffect - Side Effects#
Automatic Effects with can with entry/exit#
Similar to how has automatically generates useState, you can use can with entry and can with exit to automatically generate useEffect hooks:
cl {
def:pub DataFetcher() -> JsxElement {
has data: list = [];
has loading: bool = True;
# Run once on mount - async effects are wrapped in IIFE automatically
async can with entry {
result = await some_async_operation();
data = result;
loading = False;
}
if loading {
return <p>Loading...</p>;
}
return <ul>
{[<li key={item.id}>{item.name}</li> for item in data]}
</ul>;
}
}
Effect Dependencies#
Use list syntax [dep1, dep2] to specify dependencies (similar to React's dependency arrays):
cl {
def:pub SearchResults() -> JsxElement {
has query: str = "";
has results: list = [];
# Run when query changes
async can with [query] entry {
if query {
results = await search_api(query);
}
}
return <div>
<input
value={query}
onChange={lambda e: ChangeEvent { query = e.target.value; }}
/>
<ul>
{[<li>{r}</li> for r in results]}
</ul>
</div>;
}
}
Cleanup Effects#
can with exit generates a standalone cleanup useEffect -- use it for teardown that does not depend on a handle created at mount:
cl {
def:pub Subscriber() -> JsxElement {
has events: list = [];
can with entry {
subscribe_to_events();
}
# Cleanup on unmount
can with exit {
cleanup_subscriptions();
}
return <p>{len(events)} events</p>;
}
}
Sharing a handle between setup and cleanup
can with entry and can with exit compile to separate useEffect closures, so a variable created in entry is not visible from exit. When setup produces a handle that cleanup needs -- an interval id, a subscription, an event listener -- do the setup and teardown in a single useEffect whose callback returns the cleanup function:
cl {
import from react { useEffect }
def:pub Timer() -> JsxElement {
has seconds: int = 0;
useEffect(lambda {
intervalId = setInterval(lambda { seconds = seconds + 1; }, 1000);
# The returned function is the cleanup -- it runs on unmount
return lambda { clearInterval(intervalId); };
}, []);
return <p>Seconds: {seconds}</p>;
}
}
Manual useEffect#
The can with entry/exit syntax above is the idiomatic approach and should be preferred. However, you can also use useEffect manually by importing from React -- this is useful for complex patterns involving useRef or useCallback:
cl {
import from react { useEffect }
def:pub DataFetcher() -> JsxElement {
has data: list = [];
useEffect(lambda -> None {
fetch_data();
}, []);
return <div>...</div>;
}
}
Refs with Ref[T]#
Just as has declares reactive state (useState) and can with entry declares effects (useEffect), a has-field typed Ref[T] declares a ref -- a mutable container that persists across renders but, unlike state, does not trigger a re-render when its .current changes. It compiles to React's useRef:
cl {
def:pub FocusableInput() -> JsxElement {
has inputRef: Ref[HTMLInputElement] = Ref(); # -> const inputRef = useRef(null)
def focus() -> None {
if inputRef.current { inputRef.current.focus(); }
}
return <div>
<input ref={inputRef} type="text" />
<button onClick={lambda -> None { focus(); }}>Focus</button>
</div>;
}
}
has r: Ref[T] = Ref()compiles toconst r = useRef(null)-- an empty DOM ref. Wire it to an element withref={r}; React fills inr.currenton mount.has r: Ref[T] = Ref(initial)compiles toconst r = useRef(initial)-- a value ref seeded withinitial, for mutable values that should survive re-renders without causing one.useRefis auto-imported from React; you do not import it yourself.
The field must be constructed (= Ref() / = Ref(initial)); a bare has r: Ref[T]; is rejected (E2025) because, like every other has-field, it needs a value. .current is typed T | None, so null-check it before use.
useContext - Global State#
Creating Context#
cl {
import from react { createContext, useContext }
# Create context
glob AppContext = createContext(None);
# Provider component -- `children` is the nested JSX passed between its tags
def:pub AppProvider(children: any = None) -> JsxElement {
has user: any = None;
has theme: str = "light";
value = {
"user": user,
"theme": theme,
"setUser": lambda u: any -> None { user = u; },
"setTheme": lambda t: str -> None { theme = t; }
};
return <AppContext.Provider value={value}>
{children}
</AppContext.Provider>;
}
# Consumer component
def:pub UserDisplay() -> JsxElement {
ctx = useContext(AppContext);
if ctx.user {
return <p>Welcome, {ctx.user.name}!</p>;
}
return <p>Not logged in</p>;
}
def:pub ThemeToggle() -> JsxElement {
ctx = useContext(AppContext);
return <button onClick={lambda -> None {
ctx.setTheme("dark" if ctx.theme == "light" else "light");
}}>
Toggle Theme ({ctx.theme})
</button>;
}
def:pub app() -> JsxElement {
return <AppProvider>
<UserDisplay />
<ThemeToggle />
</AppProvider>;
}
}
Custom Hooks#
Create reusable state logic:
cl {
import from react { useEffect }
# Custom hook
def use_local_storage(key: str, initial_value: any) -> tuple {
has value: any = initial_value;
# Load from localStorage on mount
useEffect(lambda -> None {
stored = localStorage.getItem(key);
if stored {
value = JSON.parse(stored);
}
}, []);
# Save to localStorage when value changes
useEffect(lambda -> None {
localStorage.setItem(key, JSON.stringify(value));
}, [value]);
return (value, lambda v: any -> None { value = v; });
}
def:pub Settings() -> JsxElement {
(theme, set_theme) = use_local_storage("theme", "light");
return <div>
<p>Current theme: {theme}</p>
<button onClick={lambda -> None { set_theme("dark"); }}>
Dark
</button>
<button onClick={lambda -> None { set_theme("light"); }}>
Light
</button>
</div>;
}
}
State Patterns#
Loading State Pattern#
cl {
def:pub DataComponent() -> JsxElement {
has data: any = None;
has loading: bool = True;
has error: str = "";
# ... fetch data ...
if loading {
return <div className="spinner">Loading...</div>;
}
if error {
return <div className="error">{error}</div>;
}
return <div>{data}</div>;
}
}
Form State Pattern#
cl {
def:pub ContactForm() -> JsxElement {
has form_data: dict = {
"name": "",
"email": "",
"message": ""
};
has errors: dict = {};
has submitting: bool = False;
def update_field(field: str, value: str) -> None {
form_data = {**form_data, field: value};
}
def validate() -> bool {
new_errors = {};
if not form_data["name"] {
new_errors["name"] = "Name is required";
}
if "@" not in form_data["email"] {
new_errors["email"] = "Invalid email";
}
errors = new_errors;
return len(new_errors) == 0;
}
def handle_submit() -> None {
if validate() {
submitting = True;
# ... submit form ...
}
}
return <form>
<input
value={form_data["name"]}
onChange={lambda e: ChangeEvent { update_field("name", e.target.value); }}
/>
{errors.get("name") and <span className="error">{errors["name"]}</span>}
</form>;
}
}
Key Takeaways#
| Concept | Jac Syntax | React Equivalent |
|---|---|---|
| State variable | has count: int = 0 |
useState(0) |
| Update state | count = count + 1 |
setCount(count + 1) |
| Effect on mount | can with entry { ... } |
useEffect(fn, []) |
| Effect with deps | can with [dep] entry { ... } |
useEffect(fn, [dep]) |
| Cleanup on unmount | can with exit { ... } |
useEffect(() => cleanup, []) |
| Global state | useContext(Ctx) |
Same |
Next Steps#
- Backend Integration - Fetch data from walkers
- Authentication - Add user login