Advanced Patterns & JS Interop#
When building real applications, you'll encounter patterns that go beyond basic components and state. This tutorial covers WebSocket communication, JavaScript interop gotchas, module-level state, and debugging strategies for Jac client code.
These patterns are drawn from JacBuilder, a production Jac application with 150+ client-side .cl.jac files.
Prerequisites
- Completed: NPM Packages & UI Libraries
- Time: ~30 minutes
WebSocket Client#
Creating a WebSocket#
In Jac client code, use Reflect.construct() instead of the new keyword to instantiate browser built-in objects like WebSocket:
glob _ws: Any = None;
def connectWebSocket(url: str) -> None {
_ws = Reflect.construct(WebSocket, [url]);
_ws.onopen = lambda {
console.log("WebSocket connected");
};
_ws.onmessage = lambda(event: Any) {
try {
msg = JSON.parse(event.data);
handleMessage(msg);
} except Exception as e {
console.error("WS message error:", e);
}
};
_ws.onerror = lambda(e: Any) {
console.warn("WS error:", e);
};
_ws.onclose = lambda {
console.log("WebSocket closed");
};
}
Sending Messages#
def sendMessage(action: str, data: Any) -> None {
if not _ws or _ws.readyState != 1 {
console.warn("WebSocket not connected");
return;
}
msg = {
"action": action,
"data": data
};
try {
_ws.send(JSON.stringify(msg));
} except Exception as e {
console.warn("WS send failed:", e);
}
}
Request/Response Pattern with Callbacks#
For WebSocket protocols that use request IDs:
glob _nextReqId: int = 1;
glob _pendingCallbacks: Any = {};
def wsRequest(method: str, params: Any, callback: Any) -> None {
reqId = _nextReqId;
_nextReqId = _nextReqId + 1;
msg = {
"id": reqId,
"method": method,
"params": params
};
_pendingCallbacks[String(reqId)] = callback;
_ws.send(JSON.stringify(msg));
}
def handleResponse(msg: Any) -> None {
if msg and msg.id != undefined and _pendingCallbacks[String(msg.id)] {
cb = _pendingCallbacks[String(msg.id)];
_pendingCallbacks[String(msg.id)] = undefined;
cb.call(None, msg);
}
}
Constructing WebSocket URLs#
def buildWsUrl(basePath: str, token: str) -> str {
wsUrl = Reflect.construct(URL, [String(window.location.origin)]);
wsUrl.protocol = ("wss:" if window.location.protocol == "https:" else "ws:");
wsUrl.pathname = basePath;
wsUrl.search = "?token=" + encodeURIComponent(token);
return wsUrl.toString();
}
JavaScript Interop Gotchas#
Jac compiles to JavaScript, and there are several patterns where you need to work with the compiled output in mind.
Reflect.construct for new Objects#
Jac does not have a new keyword. For browser built-in constructors, use Reflect.construct():
# WebSocket
ws = Reflect.construct(WebSocket, [url]);
# URL
url = Reflect.construct(URL, [String(base)]);
# Date
now = Reflect.construct(Date, []);
# Promise
promise = Reflect.construct(Promise, [lambda(resolve: Any, reject: Any) {
# ... async work ...
resolve.call(None, result);
}]);
# CustomEvent
evt = Reflect.construct(CustomEvent, ["my-event", {"detail": {"key": "value"}}]);
window.dispatchEvent(evt);
# Map
map = Reflect.construct(Map, []);
# xterm.js Terminal
terminal = Reflect.construct(XTerminal, [termConfig]);
Callback Invocations with .call()#
When passing callbacks that will be invoked later, use .call(None, ...) to avoid issues with how Jac compiles function calls:
# Assign callback to local variable, then use .call()
msgHandler = onMessage;
ws.onmessage = lambda(e: Any) {
msg = JSON.parse(e.data);
msgHandler.call(None, msg);
};
# Promise resolve/reject
Reflect.construct(Promise, [lambda(resolve: Any, reject: Any) {
resolveFn = resolve;
rejectFn = reject;
doAsyncWork(
lambda(result: Any) { resolveFn.call(None, result); },
lambda(err: Any) { rejectFn.call(None, err); }
);
}]);
String Concatenation vs F-Strings#
F-strings in Jac client code can have issues with certain characters. When building strings with quotes or special characters, prefer concatenation:
# Prefer concatenation for strings with quotes
cmd = "[ -f \"" + path + "\" ]";
# F-strings work fine for simple cases
label = f"Count: {count}";
Newline Constants#
Literal "\n" may not work as expected in compiled JavaScript. Use String.fromCharCode():
Module-Level State with glob#
Use glob for state that persists across component renders and is shared across the module:
# Module-level state (like JavaScript module variables)
glob monacoInitialized: bool = False;
glob cachedConfig: Any = None;
glob initPromise: Any = None;
async def:pub initializeOnce() -> Any {
if monacoInitialized {
return cachedConfig;
}
if initPromise {
return await initPromise;
}
initPromise = performInit();
return await initPromise;
}
globalThis and Browser APIs#
Access browser globals through globalThis or directly:
# localStorage
localStorage.getItem("auth_token");
localStorage.setItem("auth_token", token);
localStorage.removeItem("auth_token");
# Build-time injected constants (from [plugins.client.vite.define])
version = globalThis.__APP_VERSION__;
apiBase = globalThis.__API_BASE_URL__;
# Browser APIs
window.addEventListener("resize", lambda(e: Any) { handleResize(); });
document.querySelector(".my-element");
Custom Events (Cross-Component Communication)#
glob _THEME_EVENT: str = "theme-change";
# Dispatch
def dispatchThemeChange(theme: str) -> None {
evt = Reflect.construct(CustomEvent, [
_THEME_EVENT,
{"detail": {"theme": theme}}
]);
window.dispatchEvent(evt);
}
# Listen
import from react { useEffect }
def:pub ThemeListener() -> JsxElement {
has theme: str = "light";
useEffect(lambda -> None {
handler = lambda(e: Any) {
theme = e.detail.theme;
};
window.addEventListener(_THEME_EVENT, handler);
return lambda -> None {
window.removeEventListener(_THEME_EVENT, handler);
};
}, []);
return <div className={theme}>Content</div>;
}
Async Patterns#
Async File Reading with Promises#
def readAllEntries(reader: Any) -> Any {
return Reflect.construct(Promise, [lambda(resolve: Any, reject: Any) {
allEntries: list = [];
resolveFn = resolve;
rejectFn = reject;
def readBatch() -> None {
reader.readEntries(
lambda(entries: Any) {
if not entries or entries.length == 0 {
resolveFn.call(None, allEntries);
} else {
for e in entries {
allEntries.push(e);
}
readBatch();
}
},
lambda(err: Any) { rejectFn.call(None, err); }
);
}
readBatch();
}]);
}
Debounced Auto-Save#
import from react { useRef }
def:pub useAutoSave() -> Any {
timerRef = useRef(None);
def save(path: str, content: str) -> None {
# Clear previous timer
if timerRef.current {
clearTimeout(timerRef.current);
}
# Set new 2-second debounce
timerRef.current = setTimeout(lambda {
timerRef.current = None;
writeFile(path, content);
}, 2000);
}
def flush() -> None {
if timerRef.current {
clearTimeout(timerRef.current);
timerRef.current = None;
}
}
return {"save": save, "flush": flush};
}
requestAnimationFrame for Smooth UI#
import from react { useRef }
def:pub useDrag() -> Any {
isDraggingRef = useRef(False);
rafRef = useRef(None);
lastXRef = useRef(0);
def onMouseMove(e: Any) -> None {
if not isDraggingRef.current { return; }
lastXRef.current = e.clientX;
# Batch DOM updates with RAF
if rafRef.current { return; }
rafRef.current = requestAnimationFrame(lambda {
rafRef.current = None;
applyPosition(lastXRef.current);
});
}
return {"onMouseMove": onMouseMove};
}
Debugging Client Code#
Console Logging with Context Prefixes#
Use prefixed log messages to trace issues across components and services:
# Good - prefixed for easy filtering
console.log("[useAuth] Login attempt for:", username);
console.warn("[WebSocket] Connection lost, reconnecting...");
console.error("[DataLoader] Failed to fetch:", err);
# In browser DevTools, filter by prefix: "[useAuth]"
Error Recovery with Retry Limits#
glob _errorCount: int = 0;
glob _maxRetries: int = 10;
def handleError(context: str, err: Any) -> None {
_errorCount = _errorCount + 1;
console.error(f"[{context}] Error #{_errorCount}:", err);
if _errorCount >= _maxRetries {
console.warn(f"[{context}] Max retries reached, stopping.");
return;
}
# Retry with backoff
delay = 500 * _errorCount;
setTimeout(lambda { retry(); }, delay);
}
Preventing Duplicate Operations#
import from react { useRef }
def:pub useSafeSubmit() -> Any {
sendingRef = useRef(False);
async def submit(data: Any) -> Any {
if sendingRef.current {
console.warn("[submit] Already in progress, skipping");
return None;
}
sendingRef.current = True;
try {
result = await doSubmit(data);
return result;
} except Exception as e {
console.error("[submit] Failed:", e);
return None;
} finally {
sendingRef.current = False;
}
}
return submit;
}
Build Error Diagnostics#
When client builds fail, Jac provides structured error messages:
| Code | Issue | Fix |
|---|---|---|
JAC_CLIENT_001 |
Missing npm dependency | jac add --npm <package> |
JAC_CLIENT_003 |
Syntax error in client code | Check the source snippet in the error |
JAC_CLIENT_004 |
Unresolved import | Verify import path and package name |
Enable debug mode for raw Vite output:
Or via environment variable:
Inspecting Generated JavaScript#
The compiled JavaScript lives in .jac/client/. When debugging tricky issues, inspect the generated code:
.jac/
└── client/
├── compiled/ # Generated JS from your .cl.jac files
├── dist/ # Production build output
├── configs/ # Generated config files (vite, tailwind, etc.)
└── node_modules/ # Installed npm dependencies
Browser DevTools source maps should point back to your original .jac files when available.
Common Patterns from Production Apps#
Service Layer Pattern#
Organize API calls and WebSocket logic into service modules separate from UI components:
myapp/
├── services/
│ ├── apiService.cl.jac # REST API calls
│ └── wsService.cl.jac # WebSocket management
├── hooks/
│ ├── useAuth.cl.jac # Auth state hook
│ └── useData.cl.jac # Data fetching hook
├── components/
│ └── ui/ # Reusable UI components
├── pages/ # Route pages
└── lib/
└── utils.cl.jac # cn() and other utilities
Custom Hook Pattern#
Extract reusable stateful logic into custom hooks (functions starting with use):
# hooks/usePolling.cl.jac
import from react { useRef, useEffect }
def:pub usePolling(callback: Any, intervalMs: int, enabled: bool) -> None {
timerRef = useRef(None);
callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(lambda -> None {
if not enabled { return lambda -> None {}; }
def tick() -> None {
callbackRef.current.call(None);
timerRef.current = setTimeout(tick, intervalMs);
}
tick();
return lambda -> None {
if timerRef.current {
clearTimeout(timerRef.current);
}
};
}, [intervalMs, enabled]);
}
IFrame Pointer-Events Workaround#
When dragging near iframes (common in editors/previews), the iframe steals mouse events:
def:pub PreviewPanel() -> JsxElement {
has isDragging: bool = False;
return <div>
<div
onMouseDown={lambda -> None { isDragging = True; }}
onMouseUp={lambda -> None { isDragging = False; }}
/>
<iframe
src={previewUrl}
style={{"pointerEvents": ("none" if isDragging else "auto")}}
/>
</div>;
}
Key Takeaways#
| Pattern | Jac Approach |
|---|---|
| Instantiate browser objects | Reflect.construct(ClassName, [args]) |
| Invoke callbacks | callback.call(None, arg) |
| Module-level state | glob varname: Type = value; |
| Browser globals | globalThis.X, window.X, localStorage |
| Newline character | String.fromCharCode(10) |
| Debug logging | console.log("[prefix]", data) |
| WebSocket | Reflect.construct(WebSocket, [url]) |
Next Steps#
- Backend Integration - Connect UI to server walkers
- Authentication - Add user login
- Routing - Navigate between pages