Improved Markdown Editor
Introduction
Rendering markdown in a native mobile app sounds simple until you actually try to do it well. Feed images can't overflow their preview boundaries, comment previews need to truncate mid-table without breaking layout, and every re-render has to stay cheap enough to keep a scrolling feed smooth. The MarkdownRenderer component from rn-markdown-editor was built around exactly these constraints. Rather than treating markdown-to-UI as a single messy conversion step, it separates parsing, content-limiting, and rendering into distinct, composable stages — and it's worth understanding how those stages fit together.
A Three-Stage Pipeline, Not a Monolith
The renderer's core architecture rests on a simple but powerful idea: parsing and rendering are two separate passes.
- Tokenize —
tokenize(body)converts the raw markdown string into an array of typedTokenobjects (heading,paragraph,codeBlock,table,image,video,columns, and more). This step is memoized onbody, so it only re-runs when the underlying text actually changes. - Apply content limits —
applyContentLimitstrims that token array according to truncation rules, before any component knows about layout or styling. - Map to components — the third stage walks the (possibly trimmed) tokens and renders each one to its corresponding themed block component.
Because each stage only passes Token[] to the next, none of them need to know how the others work internally. That decoupling is what makes the truncation logic — arguably the trickiest part of the whole system — possible to reason about in isolation.
Truncation That Understands Structure
Most truncation implementations render everything first and then chop the output, which tends to produce broken tags, orphaned table rows, or cut-off list markers. This renderer does the opposite: it truncates tokens, not rendered output.
applyContentLimits walks the token array while accumulating a running tokenTextLength. The moment a token would push the total past maxBodyLength, truncateToken steps in and cuts that specific token down to whatever character budget remains — trimming by line first, then by character — and appends an ellipsis. The result: a table can stop cleanly after a partial row, and a list can stop after a partial item, instead of spilling past their visual container or rendering half-formed markup.
Two Rendering Strategies, Chosen by Context
The component doesn't try to force one rendering approach onto every use case. Instead, it branches based on the numberOfLines prop:
- With
numberOfLinesset: everything collapses into a single flattened<Text>tree (flatTextBlock), with inline segments joined by literal\ncharacters. Anything that can't be represented as text — images, video, tables, columns, horizontal rules — is simply skipped. This exists because React Native's nativenumberOfLinesline-clamping only behaves reliably inside oneTextcomponent. - Without
numberOfLines: the renderer builds the full block list (blocks), giving each token its own dedicated component. Images, video, and tables render normally, exactly as authored.
This means a single component can serve both a rich, fully-rendered post view and a tightly clamped preview card — no separate "preview mode" component required.
Inline Formatting via One Alternation Regex
Inline formatting — bold, italic, underline, strikethrough, superscript, code, images, links, mentions, hashtags, bare URLs — is handled by renderInline in inline.tsx using a single combined RegExp alternation rather than a chain of independent matchers. The function dispatches on which capture group matched (m[1], m[3], m[5], and so on) instead of scanning the text once per pattern.
When a matched wrapper syntax like **, _, or ~~ is found, renderInline recurses on the captured inner text — which is what allows nested emphasis, such as bold text inside italic text, to resolve correctly without a dedicated nested-parsing branch.
Performance: Stability by Design
Two internal utilities exist purely to prevent unnecessary re-renders, which matters enormously in a scrolling list context:
useStableColorskeeps returning the same object reference across renders as long as every individual color field is unchanged — comparing field-by-field against a ref rather than doing a shallow diff on the wholecolorsobject. This stops memoized values from invalidating every time a parent passes a fresh object literal.useStableCallbackstores the latestonPressLink,onPressUser, andonPressHashtagcallbacks in a ref (kept current viauseEffect) and returns a wrapper whose identity only changes when the callback flips between defined andundefined. A parent passing a brand-new inline arrow function on every render no longer cascades into re-memoizing the block list.
Together, these two patterns let the renderer use memoization aggressively without falling into the trap of "memoized, but still recalculating every frame anyway."
Sensible Defaults Without Losing Control
A few smaller design choices stand out:
- Link handling has a platform fork baked in.
openLinkprefers a caller-suppliedonPressLink; failing that, it callsglobalThis.openon web (withnoopener,noreferrer) orLinking.openURLeverywhere else — so consumers get correct default behavior without manually branching onPlatform.OS. - Hashtag pressability is opt-in, not defaulted.
bp.onPressHashtagis only populated if the consumer actually passed anonPressHashtagprop. If not, hashtags render as plain colored text with no press handler at all, rather than silently attaching a no-op. getFirstImage(body)doesn't hook into render output at all. It independently re-runstokenizeon the same body string and inspects each token for asrc, including a separate regex for inline images embedded inside paragraphs, headings, blockquotes, lists, tables, or columns. That means you can find "the first image in this post" without rendering anything — useful for thumbnail generation or feed cards.
Key Props at a Glance
| Prop | Default | Description |
|---|---|---|
body | — | Raw markdown string to parse and render |
scrollable | false | Wraps output in a ScrollView |
baseFontSize | 14 | Base text size; scales code/sup/sub sizes relative to it |
lineHeightMultiplier | 1.6 | Multiplied by baseFontSize for line height |
colors | — | Per-instance color overrides, merged over theme defaults |
onPressLink / onPressUser / onPressHashtag | — | Press handlers for links, mentions, hashtags |
disableLinks | false | Renders interactive elements as plain text |
numberOfLines | — | Switches to single flattened <Text> mode |
maxBodyLength | — | Character budget with mid-token ellipsis truncation |
removeEmptyLine | false | Drops blank/HR/image/video tokens |
allowRenderImage | true | Skips image tokens entirely when false |
renderVideo / renderTable | true | Toggle video/table block rendering |
paddingHorizontal | 16 | Horizontal padding on the outer container |
Why This Architecture Matters
None of these individual decisions are exotic on their own. What makes the renderer effective is that each piece solves a real, specific problem — structural truncation instead of blind string-slicing, a text-only fast path for line-clamped previews, reference-stable theming to protect memoization, and an independent token walk for image extraction — without any of those solutions leaking into or complicating the others. The result is a markdown renderer that behaves predictably whether it's powering a full post view or a two-line preview card, without maintaining two separate codebases to do it.
For teams building feed-based or content-heavy React Native apps, this kind of pipeline — tokenize once, limit deliberately, render contextually — is a pattern worth borrowing even outside of markdown specifically.