Improved Markdown EditorsteemCreated with Sketch.

in Steem Devyesterday

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.

  1. Tokenizetokenize(body) converts the raw markdown string into an array of typed Token objects (heading, paragraph, codeBlock, table, image, video, columns, and more). This step is memoized on body, so it only re-runs when the underlying text actually changes.
  2. Apply content limitsapplyContentLimits trims that token array according to truncation rules, before any component knows about layout or styling.
  3. 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 numberOfLines set: everything collapses into a single flattened <Text> tree (flatTextBlock), with inline segments joined by literal \n characters. Anything that can't be represented as text — images, video, tables, columns, horizontal rules — is simply skipped. This exists because React Native's native numberOfLines line-clamping only behaves reliably inside one Text component.
  • 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:

  • useStableColors keeps 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 whole colors object. This stops memoized values from invalidating every time a parent passes a fresh object literal.
  • useStableCallback stores the latest onPressLink, onPressUser, and onPressHashtag callbacks in a ref (kept current via useEffect) and returns a wrapper whose identity only changes when the callback flips between defined and undefined. 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. openLink prefers a caller-supplied onPressLink; failing that, it calls globalThis.open on web (with noopener,noreferrer) or Linking.openURL everywhere else — so consumers get correct default behavior without manually branching on Platform.OS.
  • Hashtag pressability is opt-in, not defaulted. bp.onPressHashtag is only populated if the consumer actually passed an onPressHashtag prop. 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-runs tokenize on the same body string and inspects each token for a src, 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

PropDefaultDescription
bodyRaw markdown string to parse and render
scrollablefalseWraps output in a ScrollView
baseFontSize14Base text size; scales code/sup/sub sizes relative to it
lineHeightMultiplier1.6Multiplied by baseFontSize for line height
colorsPer-instance color overrides, merged over theme defaults
onPressLink / onPressUser / onPressHashtagPress handlers for links, mentions, hashtags
disableLinksfalseRenders interactive elements as plain text
numberOfLinesSwitches to single flattened <Text> mode
maxBodyLengthCharacter budget with mid-token ellipsis truncation
removeEmptyLinefalseDrops blank/HR/image/video tokens
allowRenderImagetrueSkips image tokens entirely when false
renderVideo / renderTabletrueToggle video/table block rendering
paddingHorizontal16Horizontal 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.