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 with has#
Inside cl { } blocks, 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: any -> None { name = e.target.value; }}
placeholder="Name"
/>
<input
value={email}
onChange={lambda e: any -> None { 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: any -> None { 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: any -> None { query = e.target.value; }}
/>
<ul>
{[<li>{r}</li> for r in results]}
</ul>
</div>;
}
}
Cleanup Effects#
Use can with exit for cleanup logic (runs on unmount):
cl {
def:pub Timer() -> JsxElement {
has seconds: int = 0;
# Setup interval on mount
can with entry {
intervalId = setInterval(lambda -> None {
seconds = seconds + 1;
}, 1000);
}
# Cleanup on unmount
can with exit {
clearInterval(intervalId);
}
return <p>Seconds: {seconds}</p>;
}
}
Manual useEffect#
You can also use useEffect manually by importing from React:
cl {
import from react { useEffect }
def:pub DataFetcher() -> JsxElement {
has data: list = [];
useEffect(lambda -> None {
fetch_data();
}, []);
return <div>...</div>;
}
}
useContext - Global State#
Creating Context#
cl {
import from react { createContext, useContext }
# Create context
glob AppContext = createContext(None);
# Provider component
def:pub AppProvider(props: dict) -> 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}>
{props.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: any -> None { 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) |
| Side effects | useEffect(fn, deps) |
Same |
| Global state | useContext(Ctx) |
Same |
| Dependencies | [var1, var2] |
Same |
Next Steps#
- Backend Integration - Fetch data from walkers
- Authentication - Add user login