A browser based UI
This article is the third part of our series on building a billion cell spreadsheet in three parts:
- Part 1: Implement core spreadsheet logic using Feldera SQL.
- Part 2: Expose pipeline data to clients via a server.
- Part 3 (this article): Add a browser-based UI.
Try the live demo and see the GitHub repo.
Introduction
We built a minimal UI for our spreadsheet using egui, a Rust-based UI framework that compiles to WebAssembly and runs in the browser.
Given our spreadsheet can span a billion cells, we don’t want to load everything into memory at once. Instead, we need lazy data fetching as users scroll. The egui_extras
Table component only renders visible rows, which helps us seamlessly load data on demand.
The code for this article is in the client
folder of our GitHub repository. Clone the repo and follow the README instructions to run and deploy the demo.
The entire UI is managed by a single update function. During runtime, egui calls this update function many times per second to determine what needs rendering.
In this article, we’ll focus on how we retrieve and cache spreadsheet cells (i.e., the client-side handling of the GET /api/spreadsheet
endpoint discussed in part 2).
Loading cell content
We connect to the /api/spreadsheet endpoint as soon as the UI loads:
let (ws_sender, ws_receiver) = {
let egui_ctx = cc.egui_ctx.clone();
let wakeup = move || egui_ctx.request_repaint();
let url = format!("{}/api/spreadsheet", server);
ewebsock::connect_with_wakeup(&url, Default::default(), wakeup).unwrap()
};
let loader = Arc::new(Loader::new(ws_sender));
We also configure the ewebsock library to call our wakeup closure whenever a new message arrives, triggering a re-render by invoking the update function. Because of that, we can handle newly received or updated cells right at the start of update
:
while let Some(event) = self.ws_receiver.try_recv() {
match event {
WsEvent::Message(WsMessage::Text(update)) => {
let parsed = serde_json::from_str::<Cell>(&update);
match parsed {
Ok(cell) => {
self.cell_cache.set(cell.id, cell.into());
}
Err(e) => {
trace!("error parsing cell update: {:?} {:?}", update, e);
}
}
}
WsEvent::Opened => {
self.loader.is_open.store(true, Ordering::Relaxed);
self.loader.fetch(0..2600);
}
WsEvent::Closed => {
self.loader.is_open.store(false, Ordering::Relaxed);
}
_ => {
error!("unexpected event: {:?}", event);
}
}
}
- On receiving a message from
/api/spreadsheet
, the code parses it as aCell
and stores it in thecell_cache
. - When the connection first opens (
WsEvent::Opened
), we request the first 2600 cells by sending{ from: 0, to: 2600 }
over the WebSocket.
Looking at the implementation of cell_cache, you’ll see it uses an LruCache
to store cell data (evicting the oldest entries when capacity is reached). Here’s the implementation of CellCache::set
:
pub fn set(&mut self, id: u64, c: CellContent) {
let mut cells = self.cells.lock();
cells.push(id, Rc::new(c));
}
Rendering the cells of the spreadsheet
We use the TableBuilder
from egui to create and render our spreadsheet table and handle mouse and keyboard events through closures provided to the API. The key lines for retrieving and rendering cell contents from the CellCache are found here:
let cell = self.cell_cache.get(id);
// ...
let cell_response = cell.ui(ui);
where Cell::ui
works as follows:
pub fn ui(&self, ui: &mut Ui) -> Response {
if self.is_editing() {
let mut content = self.write_buffer.write();
ui.add(TextEdit::singleline(&mut *content))
} else {
let content = self.content.read().to_string();
ui.add(Label::new(&content).sense(Sense::click()))
}
}
- If the cell is being edited, it shows a text box containing the
write_buffer
, initialized fromraw_value
in Feldera’sspreadsheet_view
. - Otherwise, it displays the
computed_value
.
Earlier we saw that we fetch only the first 2600 cells when the connection opens. To load additional cells as the user scrolls, we use lazy loading in CellCache::get
:
pub fn get(&mut self, id: u64) -> Rc<CellContent> {
let mut cells = self.cells.lock();
if let Some(c) = cells.get(&id) {
c.clone()
} else {
let c = Rc::new(CellContent::empty(id));
cells.push(id, c.clone());
if let Some(current_range) = &self.current_range {
if current_range.contains(&id) {
// Already fetching this range...
return c;
}
}
let start = id.saturating_sub(self.prefetch_before_after_id);
let end = id.saturating_add(self.prefetch_before_after_id);
let current_range = start..end;
self.current_range = Some(current_range.clone());
let fetcher = self.fetcher.clone();
let debouncer_clone = self.debouncer.clone();
debouncer_clone
.borrow_mut()
.debounce(Duration::from_millis(100), move || {
let mut max_retry = 10;
while !fetcher.fetch(current_range.clone()) && max_retry > 0 {
max_retry -= 1;
}
});
c
}
}
- This function returns cached
CellContent
if available. - If the cell is missing in the cache, it returns an empty cell and triggers a
{ from: start, to: end }
WebSocket request for the missing range. - A small timeout debounces these requests, preventing excessive calls if a user scrolls quickly.
Conclusion
In this article, we showed how to build a minimal UI for our real-time spreadsheet using egui and WebAssembly, focusing on on-demand data fetching and caching. With these techniques in place, we can efficiently handle billions of cells without overwhelming the client’s memory or bandwidth. Check out our GitHub repository for the full code and feel free to try the live demo to see everything in action.