react-native-streaming-markdown
v0.1.1
Published
Markdown renderer for React Native built for streaming AI responses. Renders partial markdown without flicker, handles unclosed code fences and emphasis, and keeps node identity stable across frames. Zero dependencies.
Maintainers
Readme
react-native-streaming-markdown
Markdown for React Native that is meant to be rendered while it is still arriving.
Existing markdown renderers assume you hold the whole document. An AI response does not work that way — it arrives a few characters at a time, and every frame you render is a prefix of a document that is not finished yet. Rendering those prefixes naively makes text flash bold and then un-bold, code blocks appear as paragraphs and then restyle, and React remount every node on every token.
This library parses partial markdown on purpose. Zero dependencies.
npm install react-native-streaming-markdownUsage
import { Markdown } from 'react-native-streaming-markdown';
function Message({ text, isStreaming }) {
return (
<Markdown streaming={isStreaming} cursor>
{text}
</Markdown>
);
}Pass the partial string straight through as it grows. That is the whole API for the common case.
The problem, concretely
Say the model is emitting Use **bold** here. Here is what each renderer draws
as the characters land:
| source so far | a regex renderer | this library |
| --------------- | ---------------- | ------------ |
| Use | plain | plain |
| Use ** | bold | plain |
| Use **bo | bold | plain |
| Use **bold | bold | plain |
| Use **bold** | bold | bold |
The regex renderer commits to bold the moment it sees **, before it knows the
span ever closes. If the model was actually writing 2 ** 8, it has to undo it.
That flash is the flicker.
The rule here is the opposite: an opening delimiter with no closer renders as literal text. A span turns bold once, in one direction, and never turns back.
Block-level constructs follow the inverse rule, for the same reason. An unclosed
``` fence renders as a code block immediately — a fence is always
going to become a code block, so holding it back as a paragraph would mean
restyling the entire snippet when the closing fence lands.
You can watch both rules in the terminal:
npm run build && npm run demoWhy it does not remount
Every node's key comes from the line it starts on in the source, not from its
index in an array. Append a paragraph to a document and the blocks above it keep
the keys they had, so React updates them in place instead of tearing them down.
Key by array index instead and inserting one block shifts every key below it,
remounting the whole document on a single new line.
parse('# Title\n\nFirst.').blocks.map(b => b.key) // ['b0', 'b2']
parse('# Title\n\nFirst.\n\nSecond.').blocks.map(b => b.key) // ['b0', 'b2', 'b4']Props
| prop | type | default | |
| --- | --- | --- | --- |
| children | string | — | the markdown source, partial or complete |
| streaming | boolean | true | set false once the full document has arrived |
| cursor | boolean | false | show a caret at the end while text is still arriving |
| styles | MarkdownStyles | — | override any style key; the rest are kept |
| onLinkPress | (href: string) => void | Linking.openURL | route links in-app |
| renderers | Partial<Record<BlockNode['type'], fn>> | — | replace the rendering of a block type |
Styling
Override only what you want. Everything you leave out keeps its default.
<Markdown
styles={{
text: { fontSize: 15, color: '#E6E6E6' },
codeBlock: { backgroundColor: '#16181D' },
link: { color: '#7AA2F7' },
}}
>
{text}
</Markdown>Custom block rendering
Useful for a code block that needs a copy button or syntax highlighting:
<Markdown
renderers={{
codeBlock: (node) => <HighlightedCode lang={node.lang} value={node.value} />,
}}
>
{text}
</Markdown>Using the parser on its own
The parser has no React Native dependency, so it runs in tests, on a server, or
on the web. Import it from the /parser subpath to avoid pulling in the
component:
import { parse } from 'react-native-streaming-markdown/parser';
const { blocks, pending } = parse('```js\nconst a = 1;');
// blocks[0].type === 'codeBlock', blocks[0].open === true
// pending === 'codeBlock'pending tells you the document ended mid-construct — 'codeBlock',
'emphasis', 'link', 'heading' or 'listItem' — which is how the component
decides where to put the caret.
Supported syntax
Headings (1–6), paragraphs with soft wrapping, bold, italic, strikethrough, inline code, links, hard breaks, backslash escapes, bullet and ordered lists with nesting, blockquotes, fenced code blocks with a language tag, and horizontal rules.
Deliberately not supported: HTML passthrough, tables, reference links, footnotes. They are rare in AI output and each one is a large surface to keep stable across partial input.
snake_case identifiers are left alone rather than italicised, which matters
when the model is writing code in prose.
Compatibility
React Native 0.68+ and React 17+. Works on Expo. No native modules, so there is nothing to link and it runs in Expo Go.
How it works
docs/DESIGN.md sets out the four failures a normal markdown renderer has on streaming input — flicker, restyle, remount and crash — and the rule each one is solved by.
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md.
npm install
npm test
npm run typecheckAuthor
Syed Arbab Ali Shah — @ARBAB1
License
Copyright (c) 2026 Syed Arbab Ali Shah.
Released under the MIT License. You may use it commercially, modify it and redistribute it; the copyright notice has to travel with it.
