Markdown Table Generator: From String Concatenation to Real-time Preview
Markdown Table Generator: From String Concatenation to Real-time Preview#
What’s the most annoying part of writing technical documentation? Tables. Markdown’s table syntax is simple but tedious. Manually typing | and --- gets error-prone quickly. Recently added a table generator to JsonKit, and here’s how it works.
Markdown Table Syntax Basics#
Let’s review the basic structure:
| Name | Age | City |
|-------|-----|---------|
| Alice | 28 | Beijing |
| Bob | 35 | Shanghai|
Three parts:
- Header row: Column names separated by
| - Separator row:
---for each column (at least 3 dashes) - Data rows: Values wrapped in
|
Alignment is controlled by the separator row:
| Left | Center | Right |
|:-----|:------:|------:|
| Left | Center | Right |
:---for left align:---:for center---:for right align
Core Implementation: String Concatenation#
Generating Markdown tables is essentially string concatenation, but with some edge cases:
function generateMarkdownTable(
headers: string[],
rows: string[][],
alignments: ('left' | 'center' | 'right')[]
): string {
// Header row
const headerLine = '| ' + headers.join(' | ') + ' |'
// Separator row (with alignment)
const separatorLine = '| ' + alignments.map(a => {
if (a === 'left') return ':---'
if (a === 'center') return ':---:'
return '---:'
}).join(' | ') + ' |'
// Data rows
const dataLines = rows.map(row => '| ' + row.join(' | ') + ' |')
return [headerLine, separatorLine, ...dataLines].join('\n')
}
Looks simple, but real-world usage brings edge cases.
Edge Case 1: Empty Cells#
Users might delete cell content. Don’t omit it - keep the empty string:
// Wrong: omitting empty cells breaks column count
'| Alice | 28 | |' // Correct
'| Alice | 28 |' // Wrong, missing a column
Edge Case 2: Special Characters#
Cell content containing | needs escaping:
function escapeCell(text: string): string {
return text.replace(/\|/g, '\\|')
}
Edge Case 3: Newlines#
Markdown table cells don’t support newlines. Replace with <br> or space:
function sanitizeCell(text: string): string {
return text.replace(/\n/g, ' ').replace(/\|/g, '\\|')
}
Performance: useMemo Caching#
When table data changes, Markdown output needs recalculation. Use useMemo to avoid unnecessary recomputation:
const markdown = useMemo(() => {
const headerLine = '| ' + headers.join(' | ') + ' |'
const separatorLine = '| ' + alignments.map(a => {
if (a === 'left') return ':---'
if (a === 'center') return ':---:'
return '---:'
}).join(' | ') + ' |'
const dataLines = rows.map(row => '| ' + row.join(' | ') + ' |')
return [headerLine, separatorLine, ...dataLines].join('\n')
}, [headers, rows, alignments])
Dependencies are headers, rows, alignments - only regenerate when these change.
Dynamic Row/Column Operations#
The core feature is dynamically adding/removing rows and columns:
// Add row
const addRow = useCallback(() => {
setRows(prev => [...prev, headers.map(() => '')])
}, [headers])
// Remove row
const removeRow = useCallback(() => {
setRows(prev => prev.length > 1 ? prev.slice(0, -1) : prev)
}, [])
// Add column
const addCol = useCallback(() => {
setHeaders(prev => [...prev, `Header ${prev.length + 1}`])
setRows(prev => prev.map(row => [...row, '']))
setAlignments(prev => [...prev, 'left'])
}, [])
// Remove column
const removeCol = useCallback(() => {
if (headers.length <= 1) return // Keep at least one column
setHeaders(prev => prev.slice(0, -1))
setRows(prev => prev.map(row => row.slice(0, -1)))
setAlignments(prev => prev.slice(0, -1))
}, [headers.length])
Note that removing a column updates three states: headers, rows, alignments. Missing any causes data misalignment.
Alignment Toggle Cycle#
Clicking alignment button cycles through left, center, right:
const toggleAlignment = useCallback((index: number) => {
setAlignments(prev => prev.map((a, i) => {
if (i !== index) return a
if (a === 'left') return 'center'
if (a === 'center') return 'right'
return 'left'
}))
}, [])
Using map to return a new array maintains React state immutability.
Real-time Preview Implementation#
Preview renders HTML table directly, alignment controlled by className:
<table className="w-full border-collapse">
<thead>
<tr>
{headers.map((h, i) => (
<th
key={i}
className={`
px-4 py-2 border border-border
${alignments[i] === 'center' ? 'text-center' :
alignments[i] === 'right' ? 'text-right' : 'text-left'}
`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className={`
px-4 py-2 border border-border
${alignments[ci] === 'center' ? 'text-center' :
alignments[ci] === 'right' ? 'text-right' : 'text-left'}
`}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
Preview and Markdown output share the same data source - true WYSIWYG.
Practical Details#
1. Copy to Clipboard#
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(markdown)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [markdown])
Reset copy state after 2 seconds for visual feedback.
2. Minimum Column Limit#
Check if only one column remains before deletion:
if (headers.length <= 1) return
Prevents empty table structure.
3. Default Data#
Initialize with sample data to reduce learning curve:
const [headers, setHeaders] = useState(['Header 1', 'Header 2', 'Header 3'])
const [rows, setRows] = useState([
['Cell 1', 'Cell 2', 'Cell 3'],
['Cell 4', 'Cell 5', 'Cell 6'],
])
The Result#
Based on this implementation: Markdown Table Generator
Features:
- Visual table editing
- One-click column alignment toggle
- Dynamic row/column add/remove
- Real-time preview and Markdown output
- One-click copy to clipboard
Not much code, but getting the interaction details right takes effort. Hope this helps.
Related: Markdown to HTML | Code Formatter