State Management
JWeb provides reactive state management inspired by React hooks. State changes automatically trigger UI updates via WebSocket.
Overview
Use useState() to create reactive state variables. When state changes, the component re-renders automatically.
import static jweb.State.*;
// Create state with initial value
State<Integer> count = useState(0);
// Read: count.get()
// Write: count.set(newValue)
// Update: count.update(current -> newValue)Creating State
Use useState to create reactive state in components.
import static jweb.State.*;
public class Counter implements Template {
private final State<Integer> count = useState(0);
public Element render() {
return div(
h1("Count: ", span(bind(count))), // bind renders the value and keeps it current
button(onClick(e -> count.set(count.get() + 1)), "Increment"),
button(onClick(e -> count.set(count.get() - 1)), "Decrement")
);
}
}Reading State
Use get() to read the current value.
State<String> name = useState("John");
State<Integer> age = useState(25);
State<Boolean> active = useState(true);
// Read values
String currentName = name.get();
int currentAge = age.get();
boolean isActive = active.get();
// Use in templates
p("Name: " + name.get())
p("Age: " + age.get())
when(active.get(), () -> span("Active"))Setting State
Use set() to replace the value, update() to transform it.
State<String> name = useState("John");
State<Integer> count = useState(0);
// Direct set
name.set("Jane");
count.set(10);
// Update based on current value
count.update(c -> c + 1);
count.update(c -> c * 2);
// Toggle boolean
State<Boolean> visible = useState(false);
visible.update(v -> !v);Batch Updates
Multiple updates in one event handler are batched.
State<Integer> x = useState(0);
State<Integer> y = useState(0);
button(onClick(e -> {
x.set(100); // Batched
y.set(200); // Batched
// Single re-render after both updates
}), "Move")Conditional Updates
State<Integer> count = useState(0);
// Only update if condition met
button(onClick(e -> {
if (count.get() < 10) {
count.update(c -> c + 1);
}
}), "Increment (max 10)")
// Reset to initial
button(onClick(e -> count.set(0)), "Reset")Binding State to Elements
Four element arguments keep the page in step with state, with no client code. bind and bindInput come from jweb.El; bindAttr, bindClass and live from jweb.State.
import static jweb.El.*;
import static jweb.State.*;
State<Integer> clicks = useState(0);
State<Boolean> saving = useState(false);
State<String> name = useState("");
// Text: bind renders the value, and patches it on every change
p("Clicks: ", span(bind(clicks)))
// Input: renders the value, sends every change back — two-way
input(type("text"), bindInput(name))
// Attribute and class, by truthiness
button(bindAttr(saving, "disabled"), onClick(e -> saving.set(true)), "Save")
li(bindClass(saving, "busy"), class_("row"), "Saving...")Live Regions
Anything structural — a list, a conditional, several attributes at once — goes in a live region. The body receives the state's value and returns any element; when that state changes, the server renders the body again and the runtime morphs the result into the page, keeping focus, scroll and typed input.
record User(String name) {}
State<List<String>> items = useState(new ArrayList<>());
State<User> user = useState();
State<String> filter = useState("all");
State<String> first = useState("");
State<String> last = useState("");
// One state
live(items, list -> ul(each(list, item -> li(item))))
// A conditional whose root element changes
live(user, u -> u == null
? a(href("/login"), "Sign in")
: span("Signed in as " + u.name()))
// Two states
live(items, filter, (list, f) -> ul(each(
list.stream().filter(i -> f.equals("all") || i.equals(f)).toList(),
item -> li(item))))
// Any number: the body reads them itself
live(() -> p(first.get() + " " + last.get()), first, last)
// Something to change it
button(onClick(e -> items.mutate(l -> l.add("Item " + (l.size() + 1)))), "Add")- A region re-renders only when one of its own states changes; with none listed, on every change.
- The root element carries data-live="live_N"; a text-only body is wrapped in a span.
- Event handlers inside the body are registered again on each render, like the first one.
- Regions work inside streamed Suspense blocks — the state and the region belong to the page's context.
PubSub: One Change, Every Page
A browser event changes one page. PubSub is the other direction: publish a message to a topic, and every page that subscribed re-renders what the message touched — on this instance and, with Redis, on every other. A subscription made while rendering belongs to the page: the handler runs in the page's context and the live regions it changed are patched over the page's socket.
import jweb.PubSub;
import static jweb.El.*;
import static jweb.State.*;
record Post(String author, String text) {}
String room = "42";
// In a page: subscribe once, in render(), and the live region follows the topic
State<List<Post>> posts = useState(new ArrayList<>());
PubSub.subscribe("room:" + room, Post.class, post -> posts.mutate(list -> list.add(post)));
Element wall = live(posts, list -> ul(each(list, p -> li(p.author() + ": " + p.text()))));
// From anywhere: a handler, a REST endpoint, a job, another instance
PubSub.publish("room:" + room, new Post("ada", "hello"));Outside a render a subscription is a plain listener until it is closed. Messages travel as JSON and arrive parsed to the class the subscriber asked for.
import jweb.PubSub;
record Order(long id, String status) {}
PubSub.Subscription audit = PubSub.subscribe("orders", Order.class, o -> System.out.println(o.status()));
PubSub.Subscription raw = PubSub.subscribe("orders", json -> System.out.println(json));
audit.close();
raw.close();Several instances share topics through Redis — no client library, the framework speaks the protocol itself. Set the URL and every instance with the same one is one PubSub.
jweb:
pubsub:
redis:
url: ${REDIS_URL:redis://localhost:6379}
prefix: jweb:Shared Cache and Locks
Cache.create and Cache.named live in this instance's memory. Cache.shared(name, Type.class) is what every instance sees: a Redis when jweb.cache.redis.url (or jweb.redis.url, for cache and PubSub together) is set — spoken by the framework itself, no client library — and this instance's memory otherwise, with the same semantics. Values travel as JSON.
import jweb.Cache;
record Price(String sku, long cents) {}
static Price lookup(String sku) { return new Price(sku, 100); }
static void sendDigest() {}
Cache.Shared<Price> prices = Cache.shared("prices", Price.class, Duration.ofMinutes(10));
Price p = prices.getOrSet("sku-9", () -> lookup("sku-9"));
prices.set("sku-9", p); prices.get("sku-9"); prices.delete("sku-9"); prices.clear();
Cache.shared("counts", Long.class).increment("signups", 1, Duration.ofDays(1));
// work only one instance should do: whoever takes the lock runs, the rest skip
boolean ranHere = Cache.once("digest", Duration.ofMinutes(5), () -> sendDigest());
try (Cache.Lock lock = Cache.lock("import", Duration.ofMinutes(1)).orElseThrow()) {
// held until closed, or until a dead holder's minute is up
}A store that cannot be reached is a miss, a no-op, or "not acquired" — logged, never a 500. RateLimit counts in the shared store on its own once one is configured, so perMinute(100) is a hundred per minute across the fleet; .shared(false) keeps a limiter local.
State with Objects
Store complex objects in state.
// User record or class
record User(String name, String email, boolean admin) {
User withName(String name) {
return new User(name, email, admin);
}
User withEmail(String email) {
return new User(name, email, admin);
}
}
State<User> user = useState(new User("John", "john@test.com", false));
// Update object
user.update(u -> u.withName("Jane"));
user.update(u -> u.withEmail("jane@test.com"));
// Display
div(
p("Name: " + user.get().name()),
p("Email: " + user.get().email()),
when(user.get().admin(), () -> span("Admin"))
)Nested Object Updates
record Address(String street, String city) {}
record Person(String name, Address address) {}
State<Person> person = useState(
new Person("John", new Address("123 Main", "NYC"))
);
// Update nested property - create new objects
person.update(p -> new Person(
p.name(),
new Address("456 Oak", p.address().city())
));State with Lists
Manage collections with reactive state. Mutating the list in place counts as a change — update() and mutate() both notify.
State<List<String>> items = useState(new ArrayList<>());
int index = 0;
// Add item
items.mutate(list -> list.add("New item"));
// Remove item
items.mutate(list -> list.remove(index));
// Filter items (a new list)
items.update(list -> list.stream()
.filter(item -> !item.isEmpty())
.collect(Collectors.toList()));Todo List Example
A list is structural, so it renders inside a live region: the region re-renders on the server whenever the list changes and is morphed into the page — checkboxes keep their focus, nothing else is touched.
import static jweb.State.*;
record Todo(String text, boolean done) {}
private State<List<Todo>> todos = useState(new ArrayList<>());
// Add todo
void addTodo(String text) {
todos.mutate(list -> list.add(new Todo(text, false)));
}
// Toggle todo
void toggleTodo(int index) {
todos.mutate(list -> {
Todo old = list.get(index);
list.set(index, new Todo(old.text(), !old.done()));
});
}
// Render — live(state, value -> element) re-renders this on every change
live(todos, list -> ul(each(IntStream.range(0, list.size()).boxed().toList(), i -> {
Todo todo = list.get(i);
return li(class_(todo.done() ? "todo done" : "todo"),
input(type("checkbox"), attrs().checked(todo.done()), onChange(e -> toggleTodo(i))),
span(todo.text())
);
})))Derived State
Compute values from other state.
record Todo(String text, boolean done) {}
State<List<Todo>> todos = useState(new ArrayList<>());
// Derived values (recomputed on render)
int total = todos.get().size();
int completed = (int) todos.get().stream()
.filter(Todo::done).count();
int remaining = total - completed;
div(
p("Total: " + total),
p("Completed: " + completed),
p("Remaining: " + remaining)
)Shared State
Share state between components via constructor.
record User(String name) {}
// Parent owns the state
public class App implements Template {
private final State<User> user = useState(null);
public Element render() {
return div(
new Header(user), // Pass state
new Content(user), // Same state
new Footer(user) // Same state
);
}
}
// Child receives and uses state
public class Header implements Template {
private final State<User> user;
public Header(State<User> user) {
this.user = user;
}
public Element render() {
return header(
when(user.get() != null,
() -> span("Welcome, " + user.get().name()))
);
}
}
// Content and Footer follow the same shape as Header
class Content extends Header { Content(State<User> user) { super(user); } }
class Footer extends Header { Footer(State<User> user) { super(user); } }