Polishing a WebGPU Chat UI: Progress Bars, Byte Formatting, and Controlled Inputs
Progress Bar "Makeover" + Chat Box Debut: DeepSeek-R1 WebGPU Interaction Polishing Record
Preface
Hey, all you "on-device AI explorers" who have followed along this far!
In the first part of the series, we built the static skeleton, letting React components switch interfaces with state changes like Sichuan opera face-changing; in the second part, we installed the "engine," using synthetic events to activate buttons, extracting the progress bar component, and understanding the component tree concept. But the progress bar at that time was too crude—a few <p> tags stacked with text, like appearing without makeup, completely unable to support the tech feel of "running a large model in the browser."
Today, we are going to give the project a "fine renovation": make the progress bar truly visual, filling up intuitively like download software; write an elegant formatBytes utility function to turn cold byte numbers into friendly formats like "63.7 MB"; add a chat input box to pave the way for subsequent model inference; and more importantly, deeply analyze the core React concepts of State and Props, taking your component design skills to the next level.
Ready to welcome this increasingly complete AI frontend project? Buckle up, let's go!
1. Bringing the Progress Bar to "Life": Visual Makeover of the Progress Component
The Progress component from the last lesson only returned three <p> tags, and the interface effect was just a pile of dry text. In this lesson, we made a major iteration inside the child component, turning it into a real "progress bar."
First, look at the complete code, paying attention to its top-to-bottom execution order—this is exactly the knowledge we will break down step by step:
function Progress({ text, percentage, total }) {
// Step 1: Defensive assignment
percentage ??= 0;
// Step 2: Utility function (defined outside the component, called during rendering)
return (
<div className="w-full bg-gray-100 text-left rounded-lg overflow-hidden mb-0.5">
<div
style={{ width: `${percentage}%` }}
className="bg-blue-400 whitespace-nowrap px-1 text-sm"
>
{text}
{percentage.toFixed(2)}%
{isNaN(total) ? 'N/A' : ` of ${formatBytes(total)}`}
</div>
</div>
)
}
1.1 Nullish Coalescing Assignment Operator ??= — Adding a Safety Lock to the Component
The first step of code execution is percentage ??= 0;. What does this line of code mean?
It is the "logical nullish assignment operator" introduced in ES2021 (ES12). Simply put: If the value of percentage is null or undefined, set it to 0; if a specific numerical value (including 0) has already been passed in, keep it as is.
The notes put it very well: "During initialization, there is no concept of download progress. Using ??= nullish coalescing in the component, if no value is passed and it's empty, assign it to 0. The encapsulator thinks more, the user enjoys more ease."
As the author of the Progress component, you cannot control the data coming from outside: maybe because an asynchronous request hasn't returned yet, percentage is undefined. Rather than letting the interface display "undefined%" or even throw an error, it's better to automatically normalize it to 0. Compared to the older percentage = percentage || 0, ??= only takes effect for null and undefined, and won't mistakenly replace a legitimate 0 (although replacing it here wouldn't hurt, the semantics are more precise).
This concept of "the encapsulator thinks more, the user enjoys more ease" is the core idea for building robust component libraries. With this defensive step done, percentage is a reliable number, and subsequent rendering and calculations can be used with confidence.
1.2 formatBytes: The Art of Byte Conversion
After the data is safe, we need to display it more beautifully. If the progress bar directly shows a number like 66666666, the user has to count zeros for ages. We must convert raw bytes into readable units (B, kB, MB, GB, etc.). Thus, this utility function was born:
function formatBytes(size) {
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (
+(size / Math.pow(1024, i)).toFixed(2) * 1 +
["B", "kB", "MB", "GB", "TB"][i]
);
}
Step 1: Determine the unit index
Math.log(size) / Math.log(1024) uses the logarithm change-of-base formula to calculate how many powers of 1024 size is. For example, 70,000,000 bytes ≈ 2.01, rounded down to index 2, corresponding to MB. When size == 0, directly take index 0, special handling to avoid logarithm calculation errors.
Step 2: Numerical conversion
size / Math.pow(1024, i) converts the raw bytes to the corresponding unit, .toFixed(2) keeps two decimal places, then * 1 converts back to a number to remove trailing unnecessary zeros (e.g., 1.50 → 1.5).
Step 3: Concatenate the unit
Use the index to retrieve the unit from the array ["B", "kB", "MB", "GB", "TB"], returning a readable string like "63.74 MB".
In the component, isNaN(total) ? 'N/A' : of ${formatBytes(total)} ensures N/A is displayed when total is abnormal, enhancing robustness.
1.3 The Magic from "Text List" to "Percentage Width"
After the data is prepared, it's finally time for the rendering stage. The design idea of the progress bar is very simple: The container is 100% width, and the child element's width is determined by props.percentage. This is exactly the implementation of the note's statement: "Container 100%, child element (progress bar, width grows by props percentage)."
The outer div uses w-full bg-gray-100 rounded-lg overflow-hidden, filling the parent container, gray background, rounded corners, and the key overflow-hidden ensures the inner progress bar doesn't exceed the rounded border.
The inner div is the core of the progress indicator. Its width is dynamically bound via inline style:
style={{ width: `${percentage}%` }}
Note the double curly braces here: the outer {} is JSX expression syntax, and the inner {} is a JavaScript object literal. React will generate the inline style width: 45% for this element (assuming percentage is 45), and whenever percentage changes, React automatically updates the DOM style, and the progress bar "grows."
This declarative UI update is the embodiment of React's charm—you only need to declare "width equals percentage%", and the framework handles how and when it changes.
Now look at the inner div's className: "bg-blue-400 whitespace-nowrap px-1 text-sm". Here, whitespace-nowrap is a very practical little detail. The text information in the progress bar (file name, percentage, total size) might be very long. If the progress bar width is small, the text would automatically wrap by default, stretching the progress bar to several lines in height, greatly affecting aesthetics. whitespace-nowrap forces all text to display on a single line, even if it exceeds the progress bar width. Combined with the outer overflow-hidden, the overflowing part is elegantly clipped, ensuring the progress bar is compact and tidy. This combination of atomic classes is like putting a well-fitting outfit on the component—too much is too wide, too little is too tight.
2. State and Props: The Component's "Personal Property" and "Family Trust"
During the component encapsulation and the update of the parent component App, we repeatedly encountered two types of data. The notes specifically summarized them:
"State is status data, declared with useState, representing the component's own state, managed by the component itself. Props are passed data, unidirectional, can only be passed from parent component to child component, cannot modify the parent component's state in the child component; reporting to the parent component is the only way to modify. Child components are mainly responsible for display; whatever props the parent component gives me, I display accordingly. Component encapsulation and robustness."
State is like the component's "personal property." Declared with useState, owned by the component itself, and can be modified at any time via setState.
Props are like a "family trust"—read-only assets passed from parent component to child component. A child component cannot directly modify props; when a change is needed, it must "report and apply" through a callback passed down by the parent component. The parent component personally modifies its own state, and then the new props flow down again.
This unidirectional data flow makes data changes traceable, preventing the eerie bug of "some value being inexplicably tampered with." The Progress component is a typical example: give me percentage: 45, I render 45% width; give me percentage: 78, I render 78% width. It doesn't care where the data comes from, it just displays.
3. Chat Input Box Online: Controlled Components and Event Details
Once the model is loaded, there must be a place to input questions, right? Below the Load Model button, we added a textarea and implemented a complete interaction loop around it.
First, look at the complete code to feel the "ritual sense" of React handling forms:
<div className="mt-2 border border-gray-300 rounded-lg w-[600px]
max-w-[80%] max-h-[200px] mx-auto relative mb-3 flex">
<textarea
className="w-[550px] dark-gray-700 px-3 py-4 rounded-lg
bg-transparent border-none outline-hidden
disabled:text-gray-400 disabled:placeholder-gray-200"
placeholder="Type your message here"
rows={1}
disabled={status !== 'ready'}
value={input}
onInput={e => {
const target = e.target as HTMLTextAreaElement;
setInput(target.value);
}}
onKeyDown={e => {
if (input.length > 0 && e.key === 'Enter' && !e.ctrlKey) {
e.preventDefault();
onSubmit();
}
}}
title={status === 'ready' ? 'model is ready' : 'model is not ready'}
/>
</div>
Let's break it down step by step.
3.1 Controlled Components: Why Doesn't React Use Two-Way Binding?
There is a very key comment in the notes:
"React does not support two-way binding because performance is not very good"
Behind this is actually the core embodiment of React's data flow philosophy. Vue's v-model is indeed a sharp tool for two-way binding: you modify the input box, the data changes automatically; you modify the data, the input box changes automatically. But in React's view, this kind of "magic" can bring hidden dangers in large applications—who exactly changed the data? When was it changed? Debugging is like tracking a river flowing in the dark.
React chose a more "clumsy" but more "transparent" approach: controlled components.
So-called controlled means making React's state the single source of truth for the form value. Specifically for our textarea, it's done in two steps:
- Display layer:
value={input}binds theinputvalue in the state to the text box. At any time, the content displayed in the text box is strictly equal to theinputstate. - Modification layer: When the user types on the keyboard, it doesn't directly change the DOM value of the text box (because React has taken over), but triggers the
onInputevent. In the event callback, we callsetInput(target.value)to "report" the new text to the state. Once the state updates, React re-renders,valuebecomes the latest value again, and the text box content updates accordingly.
In this way, the entire data flow forms a perfect unidirectional cycle: state → value → user input → onInput → setState → new state → new value. Every step is explicit and traceable. Anytime you want to know what's in the text box, just look at the input state, no need to dig through the DOM.
Moreover, this explicit control brings extra flexibility: for example, in onInput, you can easily do formatting, limit character count, real-time validation, etc. All logic is in JS, clear at a glance.
3.2 Type Assertion: Telling TypeScript "This Guy is a textarea"
In the onInput callback, there's a line of code that looks a bit strange:
const target = e.target as HTMLTextAreaElement;
Why the extra step, why not just use e.target.value directly? Because TypeScript won't allow it.
React's synthetic event object e is a generic structure, and its target property type is EventTarget—a very broad type that only includes general methods like addEventListener, without a value property. The notes explain it very vividly:
"The event object will definitely have a target property, but not necessarily a value property. e is a general event object, not necessarily having value. e.target.value is a property that form elements have; ordinary div box click events do not have a value property."
But we know in our hearts: this onInput is written on a <textarea>, and at runtime e.target must be an HTMLTextAreaElement instance, which certainly has a value property. So we need type assertion (the as keyword) to explicitly tell TypeScript: "Please trust me, I guarantee it's a textarea, please allow me to access its value."
This is like going to the bank to do business; the counter needs a specific form, but the document in your hand is titled "General Document." You tell the teller: "I'm sure this is that form, please process it as such." as HTMLTextAreaElement is exactly that sentence. It doesn't change any runtime behavior; it's purely to let the TypeScript compiler pass, while retaining other benefits of type checking.
3.3 Keyboard Events and State Machine: Making the Enter Key "Send" Messages
The user types in the input box and ultimately wants to send it to the model. We chose the most chat-habit-friendly way: press Enter to send, while retaining the possibility of Ctrl+Enter for line breaks.
onKeyDown={e => {
if (input.length > 0 && e.key === 'Enter' && !e.ctrlKey) {
e.preventDefault();
onSubmit();
}
}}
e.key === 'Enter': Thekeyproperty is the standardized key value for keyboard events, more intuitive than the oldkeyCode.!e.ctrlKey:ctrlKeyis a boolean value indicating whether the Ctrl key was held down when the event was triggered. If the user pressed Ctrl+Enter, we do nothing, letting the browser default to a line break; if only Enter was pressed, we enter the send logic.e.preventDefault(): Prevents the default behavior of the Enter key—which in a textarea is a line break. We don't want an extra newline character in the text box when sending a message.input.length > 0: Don't send blank messages, polite and resource-saving.
These details combined can polish an interaction experience that "understands the user."
Finally, let's look at onSubmit, which is the state hub of the current flow:
const onSubmit = () => {
if (input.length > 0 && status === 'ready') {
setStatus('loading');
setLoadingMessage('Reasoning...');
setError(null);
setProgressItem([]);
}
}
Once submission is triggered, it instantly pulls the interface from "ready" back to "loading": the status becomes loading, the top message changes to "Reasoning...", and previous errors and progress lists are cleared. Although real model inference hasn't been connected yet, the entire state flow is already in full readiness—like a race car that has warmed its tires, just waiting for the starting gun.
4. Next Episode Preview: The Model is Really Going to Download!
In this lesson, we added a beautiful progress bar, humanized byte display, reasonable State/Props division of labor, and complete chat input logic to the project. Now only the last step remains: connecting real model downloading and inference.
In the next episode, we will introduce Transformers.js to pull the ONNX model files of the DeepSeek-R1 distilled version from HuggingFace. You will see the progress bar really "run," the conversation capability activated, and a fully offline browser-side large model chatbot officially born!
Hit follow, don't miss the most shocking fourth episode!
5. Summary
In today's "fine renovation" lesson, we brought a qualitative leap to the DeepSeek-R1 WebGPU project:
- 🛡️ Defensive Programming:
??=nullish coalescing adds a safety lock to the component; the encapsulator is thoughtful, the user is worry-free. - 📏 Byte Unit Conversion:
formatByteselegantly converts using logarithms, numbers are no longer cold. - 🎨 Progress Bar Visualization: Dynamic
style+whitespace-nowrapcreates a professional-grade progress bar. - 📊 State vs Props: Understand "personal property" and "family trust," establish unidirectional data flow thinking.
- 💬 Chat Input Box: Controlled components, type assertions, keyboard events, paving the way for inference conversations.
The project has gradually evolved from an empty shell into a real application with flesh, interaction, and a sense of design. Every newly added line of code, every component iteration, is solidifying your React internal skills.
If this article has inspired you, don't forget to like, bookmark, and share~ If you have any questions or wild on-device AI ideas, the comment section is our tech living room, speak freely! See you in the fourth episode! 👋