LCS text diff with a React renderer
Compute a line-by-line diff using the longest common subsequence and render additions and deletions in a React view.
React / Next.jsUtilitiesdifflcstextreact
Code
export type Row = { kind: 'same' | 'add' | 'del'; text: string };
export function diffLines(a: string, b: string, { trim = false } = {}): Row[] {
const A = a.replace(/\r\n/g, '\n').split('\n');
const B = b.replace(/\r\n/g, '\n').split('\n');
const key = (s: string) => (trim ? s.trim() : s); // compare normalised, display original
// lcs[i][j] = LCS length of A[i..] and B[j..]
const lcs = Array.from({ length: A.length + 1 }, () => new Uint32Array(B.length + 1));
for (let i = A.length - 1; i >= 0; i--) {
for (let j = B.length - 1; j >= 0; j--) {
lcs[i]![j] = key(A[i]!) === key(B[j]!) ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
}
}
const rows: Row[] = [];
let i = 0;
let j = 0;
while (i < A.length && j < B.length) {
if (key(A[i]!) === key(B[j]!)) rows.push({ kind: 'same', text: B[j++]! }), i++;
else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) rows.push({ kind: 'del', text: A[i++]! });
else rows.push({ kind: 'add', text: B[j++]! });
}
while (i < A.length) rows.push({ kind: 'del', text: A[i++]! });
while (j < B.length) rows.push({ kind: 'add', text: B[j++]! });
return rows;
}
const style = {
same: { sign: ' ', cls: '' },
add: { sign: '+', cls: 'bg-emerald-50 text-emerald-800' },
del: { sign: '-', cls: 'bg-rose-50 text-rose-800' },
} as const;
export function DiffView({ rows }: { rows: Row[] }) {
return (
<pre className="overflow-x-auto rounded-lg border font-mono text-sm leading-6">
{rows.map((r, i) => (
<div key={i} className={`whitespace-pre px-3 ${style[r.kind].cls}`}>
<span aria-hidden className="mr-3 select-none opacity-60">{style[r.kind].sign}</span>
{r.text || ' '}
</div>
))}
</pre>
);
}
How it works
What it solves
Comparing two versions of text is a daily task, whether it is a config file, a contract clause or a changelog. Pulling in a diff library is overkill for line-level comparison: the core algorithm fits in a few dozen lines and gives you full control of the output.
How it works
diffLines splits both strings into lines and builds a table where cell [i][j] holds the length of the longest common subsequence of the first i lines of A and the first j lines of B. A line match extends the diagonal, otherwise the cell takes the larger of its top and left neighbours. This is classic dynamic programming and costs time and memory proportional to the product of the two line counts.
The script then walks the table backwards from the bottom-right corner. Matching lines are emitted as same; when the left value is greater it emits an add from B, otherwise a del from A. Because the walk goes backwards, the result is reversed at the end. Trailing and leading whitespace can be ignored with the trim option, which normalises lines only for comparison while keeping original text for display.
DiffView maps each entry to a row with a gutter sign and background colour, using whitespace-pre so indentation survives.
Adapting it
- Diff words by splitting on spaces instead of newlines.
- Collapse long unchanged runs by keeping three lines of context around changes.
- Count additions and deletions for a summary badge.
Pitfalls
- Memory grows quickly for files over a few thousand lines; use a Myers diff for big inputs.
- Compare line endings consistently by normalising CRLF first.
How to use it
- Save as
lib/diff.tsx(no packages). const rows = diffLines(oldText, newText, { trim: true }).- Render
<DiffView rows={rows} />or maprowsyourself. - Each row has
kind(same,add,del) andtext.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.