react-shiki: Shiki syntax highlighting that actually fits React

February 22, 2025

|repo-review

by Florian Narr

react-shiki: Shiki syntax highlighting that actually fits React

react-shiki wraps Shiki in a React hook and component that handles the async lifecycle for you — language loading, theme resolution, streaming throttling — while keeping the output either as React nodes or raw HTML strings depending on what you need.

Why I starred it

The standard Shiki experience in React is awkward. Shiki's highlighter is async by design — you initialize it, load languages and themes, then call codeToHast. None of that maps cleanly to React's render cycle. The usual solutions are either server-side only (RSC), or they involve managing refs and effects yourself, or reaching for react-syntax-highlighter which uses Prism under the hood and has been in maintenance mode for years.

react-shiki handles the lifecycle and exposes two clean APIs: a useShikiHighlighter hook for when you need to compose, and a ShikiHighlighter component when you just want drop-in code blocks.

How it works

The hook in package/src/lib/hook.ts does something worth examining. Every call to useShikiHighlighter with object arguments (themes, options) would normally trigger a re-render on every parent render because objects don't have referential stability. The library solves this with useStableValue in utils.ts:

export const useStableValue = <T>(value: T): T => {
  const ref = useRef(value);

  if (value !== ref.current && !dequal(value, ref.current)) {
    ref.current = value;
  }

  return ref.current;
};

This runs reference equality first (fast path), falls back to deep equality via dequal only when references differ. Language objects, theme configs, and options all go through this before feeding into useMemo dependencies. Clean and cheap.

The highlighter itself is a singleton factory pattern. package/src/bundles/full.ts calls getSingletonHighlighter from Shiki — which means language and theme loading is deduplicated across every instance on the page. Shiki handles lazy language loading natively; this just makes sure each entry point (full, web, core) gets its own properly scoped singleton.

The streaming throttle is also worth noting. Instead of debouncing on the trailing edge (which adds latency at the end), it uses a nextAllowedTime watermark:

const delay = Math.max(0, timeoutControl.current.nextAllowedTime - now);
timeoutControl.current.timeoutId = setTimeout(() => {
  performHighlight().catch(console.error);
  timeoutControl.current.nextAllowedTime = now + throttleMs;
}, delay);

Each highlight fires immediately if we're past the allowed time, or waits exactly long enough if we're not. No trailing accumulation, no input lag after a pause. This is the right approach for LLM streaming chat UIs where code blocks update on every token.

The output format choice is deliberate. Default React output goes through codeToHasttoJsxRuntime, which produces actual React nodes with no dangerouslySetInnerHTML. The HTML path goes through codeToHtml and requires dangerouslySetInnerHTML on render, but it's measurably faster — the bench in package/tests/performance.bench.ts tests all three approaches (HAST to JSX runtime, HTML to html-react-parser, HTML to dangerouslySetInnerHTML) across small, medium, large, and very-large code samples. The README claims 15-45% faster for the HTML path.

The three bundle options match what Shiki itself offers: full (~1.2MB gzipped), web (~707KB, web-focused languages), and core (~12KB + whatever you import). The core bundle lets you pass a pre-built highlighter directly via the highlighter prop, bypassing the factory entirely.

Using it

Basic usage with the component:

import ShikiHighlighter from "react-shiki";

function CodeBlock() {
  return (
    <ShikiHighlighter language="tsx" theme="github-dark">
      {code.trim()}
    </ShikiHighlighter>
  );
}

Multi-theme with CSS light-dark() for automatic OS-driven switching:

<ShikiHighlighter
  language="tsx"
  theme={{ light: "github-light", dark: "github-dark" }}
  defaultColor="light-dark()"
>
  {code.trim()}
</ShikiHighlighter>

The react-markdown integration story is covered, including a workaround for the inline prop that react-markdown v9 removed. The library exports both isInlineCode (parses the HAST node to detect inline code) and rehypeInlineCodeProperty (a rehype plugin that reintroduces the inline prop).

For streaming, pass a delay in milliseconds:

const highlighted = useShikiHighlighter(code, "tsx", "github-dark", {
  delay: 150,
});

Rough edges

The test suite is decent — vitest, separate files per concern (hook.test.tsx, component.test.tsx, language.test.ts, theme.test.ts), plus the performance benchmarks. The benchmark file has a comment noting it was "written by AI", which is honest.

The component doesn't support SSR out of the box. This is client-side highlighting only — the hook uses useEffect, so it renders nothing on the server and highlights on mount. For a Next.js app with RSC, you'd want @shikijs/markdown-it or the Shiki integration for your markdown processor instead. The README doesn't call this out loudly enough.

Line numbers are CSS-counter-based, which is fine, but the CSS class names went through a breaking rename recently (from .line-numbers/.has-line-numbers to .rs-line-number/.rs-has-line-numbers) — the legacy names are deprecated with a note they'll be removed in the next major. Worth checking if you're upgrading from an older version.

The embedded language detection (e.g., Python inside Markdown fences) calls guessEmbeddedLanguages from shiki/core and auto-loads whatever it finds in the bundle. This is automatic and useful, but it means unexpected language imports if you're on the full bundle and trying to keep network requests minimal.

Bottom line

If you're building a client-side React app that needs syntax highlighting — chat interfaces, playgrounds, documentation with theme switching — this is the cleanest wrapper around Shiki I've seen. Don't use it for RSC or static generation; Shiki's native integrations handle those better.

484 stars, active maintenance (latest commit April 2026), published on npm as react-shiki.

AVGVSTVS96/react-shiki on GitHub
AVGVSTVS96/react-shiki