Skip to main content

Command Palette

Search for a command to run...

Building a Multi-Language Code Beautifier in React — Language-Specific Option Sets, Explicit Trigger, and Error Handling

How a single Language discriminated union drives three separate js-beautify function calls, why the beautifier runs on click rather than on input, and what each per-language option actually does.

Updated
7 min read
Building a Multi-Language Code Beautifier in React — Language-Specific Option Sets, Explicit Trigger, and Error Handling

A code beautifier that handles HTML, CSS, and JavaScript looks simple on the surface — paste code, click a button, get indented output. The interesting decisions are in the details: one component managing three different formatter option sets, explicit trigger vs. live mode, and how to handle malformed input without crashing.

Here's how the Code Beautifier at Ultimate Tools is built using js-beautify and React.


The Language Type as a Dispatch Key

Three languages, three separate beautify functions, one state field:

type Language = "html" | "css" | "javascript";

const [language, setLanguage] = useState<Language>("html");

Language is a discriminated union. It drives the tab UI, the textarea placeholder, and — critically — which beautify.* function gets called:

const handleBeautify = () => {
    if (!input.trim()) return;
    setError("");
    try {
        let result = "";
        const opts = { indent_size: indentSize, preserve_newlines: false, end_with_newline: true };

        if (language === "html") {
            result = beautify.html(input, { ...opts, indent_inner_html: true, wrap_line_length: 120 });
        } else if (language === "css") {
            result = beautify.css(input, opts);
        } else {
            result = beautify.js(input, {
                ...opts,
                space_before_conditional: true,
                unescape_strings: false,
                jslint_happy: false,
                e4x: false,
                brace_style: "collapse",
            });
        }
        setOutput(result);
    } catch (e: any) {
        setError("Could not parse the code. Check for syntax errors and try again.");
    }
};

The shared opts object holds options that apply across all three languages. Language-specific options are spread in after.


The Shared Options

const opts = {
    indent_size: indentSize,     // 2 or 4 — user-controlled
    preserve_newlines: false,    // collapse all blank lines
    end_with_newline: true,      // always add a trailing newline
};

preserve_newlines: false

This is the most impactful option for the tool's primary use case: reformatting minified code. Minified code has no meaningful blank lines — every blank line is a formatting accident. Setting preserve_newlines: false collapses all blank lines so the output is consistently compact. If you set it to true, js-beautify preserves whatever blank lines exist in the input, which for minified code produces unpredictable gaps.

end_with_newline: true

Forces a trailing newline on the last line. This matches the convention most editors enforce (VS Code adds a trailing newline on save). It also means the output is directly pasteable into most linters without a "no-newline at end of file" warning.


HTML-Specific Options

{ ...opts, indent_inner_html: true, wrap_line_length: 120 }

indent_inner_html: true

Without this, <head> and <body> contents stay at column 0:

<!-- indent_inner_html: false -->
<html>
<head>
<title>Page</title>
</head>
<body>
<h1>Hello</h1>
</body>
</html>

<!-- indent_inner_html: true -->
<html>
  <head>
    <title>Page</title>
  </head>
  <body>
    <h1>Hello</h1>
  </body>
</html>

The default (false) exists for legacy reasons — some older codebases intentionally don't indent inside <html>. For a general-purpose formatter, indenting the inner content is the expected behavior.

wrap_line_length: 120

Long attribute lists on a single element get wrapped at 120 characters. This prevents extremely long lines in output while still being permissive enough that most tags don't get split unnecessarily. The js-beautify default is 250, which is almost never reached. Setting 120 produces more readable output for typical HTML.


JavaScript-Specific Options

{
    ...opts,
    space_before_conditional: true,
    unescape_strings: false,
    jslint_happy: false,
    e4x: false,
    brace_style: "collapse",
}

space_before_conditional: true

Adds a space before if, for, while:

// false:   if(x)
// true:    if (x)

Standard JavaScript style. The default is true in most formatters; setting it explicitly prevents any environment from overriding it.

brace_style: "collapse"

Controls where opening braces appear:

// "collapse" — brace on same line as statement
function foo() {
    return 1;
}

// "expand" — brace on next line
function foo()
{
    return 1;
}

"collapse" matches standard JavaScript (K&R style). The other options (expand, end-expand, none) exist for teams with different conventions; "collapse" is the right default for a general-purpose tool.

unescape_strings: false

Keeps escape sequences in strings as-is. With true, js-beautify would convert A to A in string literals — useful for debugging minified code but destructive if you need the escape sequences preserved in the output.

jslint_happy: false

Enabling this adds extra whitespace to satisfy JSLint's stricter rules (spaces inside array brackets, etc.). Most code doesn't need this, and it produces output that doesn't match Prettier or ESLint defaults.

e4x: false

E4X (ECMAScript for XML) is a defunct Mozilla extension for embedding XML literals in JavaScript. It's off. Enabling it would break parsing of standard JSX-adjacent syntax.


Explicit Trigger vs. Live Mode

The beautifier runs only when the user clicks the Beautify button — not on every keystroke:

const [input, setInput]   = useState("");
const [output, setOutput] = useState("");

output is set only inside handleBeautify. There's no useEffect watching input.

Why? js-beautify runs synchronously on the main thread. For a 50KB minified JS file, that's a noticeable pause — enough to cause a keypress-to-character lag if triggered on every onChange. An explicit trigger makes the latency predictable: it happens when the user asks for it, not while they're still typing or pasting.

The output <pre> also resets to empty when the language tab changes, so the user always sees fresh output after switching:

onClick={() => { setLanguage(lang); setOutput(""); setError(""); }}

Error Handling

js-beautify throws on severely malformed input — broken HTML tag structures or JavaScript with mismatched braces. The try/catch surfaces a human-readable message instead of a stack trace:

try {
    // ... beautify call
    setOutput(result);
} catch (e: any) {
    setError("Could not parse the code. Check for syntax errors and try again.");
}

setError("") at the top of handleBeautify clears any previous error before each run — so a second successful attempt after a failed one always shows the output, not the stale error.


Output: <pre> Not <textarea>

The beautified result renders in a <pre> tag rather than a readonly textarea:

<pre className="w-full rounded-md border ... font-mono text-sm overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre">
    {output}
</pre>

<pre> preserves whitespace exactly — every space and newline in the formatted output appears exactly as-is. A <textarea> would also preserve whitespace, but it invites editing, and the output should be read-only. overflow-x-auto handles long lines (especially in HTML with deeply nested attributes) without breaking the layout.


The Indent Toggle

const [indentSize, setIndentSize] = useState<2 | 4>(2);

The type 2 | 4 (not number) means the value can only ever be 2 or 4 — no invalid inputs, no validation required. The toggle renders both options as buttons:

{([2, 4] as const).map((n) => (
    <button key={n} onClick={() => setIndentSize(n)} ...>
        {n} spaces
    </button>
))}

as const on the array prevents TypeScript from widening [2, 4] to number[]. Without it, n would be inferred as number, which wouldn't satisfy the 2 | 4 state setter.


Per-Language Placeholders

Each language tab has its own placeholder showing what minified code looks like before beautification:

const PLACEHOLDERS: Record<Language, string> = {
  html: `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Example</title></head><body><h1>Hello World</h1><p>Paste your minified HTML here.</p></body></html>`,
  css: `body{margin:0;padding:0;font-family:sans-serif}h1{color:#333;font-size:2rem;margin-bottom:1rem}.container{max-width:1200px;margin:0 auto;padding:0 1rem}`,
  javascript: `function calculateTax(income,rate){if(!income||!rate){return 0;}const tax=income*rate/100;return Math.round(tax*100)/100;}const result=calculateTax(500000,30);console.log('Tax:',result);`,
};

These aren't just example text — they're genuine minified snippets. When a user opens the tool and clicks Beautify without pasting anything, the placeholder gives them an immediate demo of what the tool does.


Try It

The beautifier is live at Code Beautifier — paste minified HTML, CSS, or JavaScript, set the indent size, click Beautify. Part of Ultimate Tools, a free collection of browser-based developer tools.

More from this blog

U

Ultimate Tools — Developer Blog

129 posts