Get Started for FREE

How to Integrate a Rich Text Editor in a React TypeScript Project

React-TypeScript Rich Text Editor Tutorial

Quick answer: Install froala-editor and react-froala-wysiwyg, and import the stylesheets and plugins once.  Then render the editor as a controlled component with model and onModelChange, and type your config with Partial<FroalaOptions> so events and options are checked by the compiler.

One of the easiest parts of adding a React TypeScript rich text editor within your application is rendering an editable area. The most challenging parts are content, state, configuration, plugins, events, and the editor’s overall communication with your app. On top of that, TypeScript adds one essential requirement. The integration must smoothly fit into a typed codebase.

This tutorial walks through that integration with Froala. Every snippet below was compiled with strict: true and built with Vite, using React 19.3, TypeScript 5.9, Vite 7.3, froala-editor 5.4.0, and react-froala-wysiwyg 5.4.0. 

What Do You Need to Build a React TypeScript Rich Text Editor?

You need Node.js, npm, and either an existing React TypeScript app or a new one. For a new project, scaffold with Vite. React’s own documentation no longer recommends Create React App, so this tutorial skips it.

npm create vite@latest my-editor-app -- --template react-ts
cd my-editor-app
npm install

The Vite template ships with strict, verbatimModuleSyntax and noUnusedLocals enabled. All code below passes under those settings.

How to Install Froala in a React TypeScript Project

This tutorial does not require a Froala activation key to run the Froala editor. We are using the free and unlicensed version of Froala. When you are ready for production, you can always add the key setup (see the production checklist near the end).

npm install froala-editor react-froala-wysiwyg

Centralize the stylesheets and plugin bundle in one file, src/froala-setup.ts, and import it once from your entry point. This keeps every component free of repeated side-effect imports.

// src/froala-setup.ts
import 'froala-editor/css/froala_editor.pkgd.min.css';
import 'froala-editor/css/froala_style.min.css';
import 'froala-editor/js/plugins.pkgd.min.js';

 

// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './froala-setup';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

The two stylesheets do different jobs. froala_editor.pkgd.min.css styles the editor UI, including the toolbar. froala_style.min.css styles the content itself (the .fr-view class), so you also want it wherever you render saved HTML outside the editor.

plugins.pkgd.min.js loads every plugin, which is convenient in development. For production, import only the plugins you enable, for example froala-editor/js/plugins/link.min.js, to keep the bundle smaller.

How Do You Add the Rich Text Editor Component?

Here is BasicEditor.tsx, the first working example. In practice, the wrapper’s default export doesn’t always resolve to the component itself under Vite. Depending on the exact Vite/react-froala-wysiwyg version pairing, the import can resolve to the module’s raw exports object instead, which throws Element type is invalid... but got: object at render time. Rather than debug that per project, unwrap it once in a typed helper and import the component from there everywhere else:

// src/FroalaEditor.ts
import * as FroalaModule from 'react-froala-wysiwyg';
import type FroalaEditorComponent from 'react-froala-wysiwyg';
type ModuleShape = { default?: { default?: unknown } };
const mod = FroalaModule as ModuleShape;
const resolved = (mod.default?.default ?? mod.default ?? FroalaModule) as typeof FroalaEditorComponent;
export default resolved;

 

// src/BasicEditor.tsx
import { useState } from 'react';
import FroalaEditorComponent from './FroalaEditor';

export default function BasicEditor() {
  const [content, setContent] = useState<string>('<p>Hello from React!</p>');

  return (
    <>
      <FroalaEditorComponent
        tag="textarea"
        model={content}
        onModelChange={setContent}
      />
      <h3>Live React state</h3>
      <pre>{content}</pre>
    </>
  );
}

tag tells the wrapper which element to initialize the editor on; textarea is the standard choice for a full editing surface. model and onModelChange connect the editor to React state, and the <pre> underneath renders that state live so you can watch the HTML string change as you type.

Drop this component into a page, and you have a working, styled editor. Everything else in the tutorial builds on it.

How Do You Manage Editor Content in React?

model and onModelChange make this a standard controlled component. model is the HTML the editor should show; onModelChange fires with the current HTML string whenever the editor records a content change. setContent from useState already matches the expected signature, so no wrapper function is needed.

Two details about update timing are worth knowing. The wrapper drives onModelChange from Froala’s contentChanged event, which the editor batches during typing using its typing timer rather than firing on every keystroke. If you need updates on each key release, set immediateReactModelUpdate: true in the config; the wrapper then also listens to keyup. That costs performance on large documents, so leave it off unless you need it.

Keep content in local state for a single self-contained editor. Once it needs to reach a form, an API call, or a sibling component, lift it to a parent or a shared state layer, as PostForm does later in this tutorial.

How Do You Configure a React Rich Text Editor with TypeScript?

Inline JSX options get unwieldy past two or three keys. Pull them into a config object, and type it with Partial<FroalaOptions> from froala-editor. That single annotation is what gives you autocomplete on option names and type errors on wrong values. It also types the this context inside event callbacks, which matters in the next section.

// src/ConfiguredEditor.tsx
import { useState } from 'react';
import type { FroalaOptions } from 'froala-editor';
import FroalaEditorComponent from './FroalaEditor';

const config: Partial<FroalaOptions> = {
  placeholderText: 'Start writing...',
  charCounterCount: true,
  heightMin: 300,
  toolbarButtons: ['bold', 'italic', 'underline', 'insertLink'],
};

export default function ConfiguredEditor() {
  const [content, setContent] = useState<string>('');

  return (
    <FroalaEditorComponent
      tag="textarea"
      config={config}
      model={content}
      onModelChange={setContent}
    />
  );
}

 A shared config object reads better than a JSX prop with a dozen inline keys, and the same object can back every editor instance in the app instead of retyping toolbar buttons per screen.

One note on where the strictness lives. The wrapper’s own prop types are loose: config, onModelChange and onManualControllerReady are declared as object. TypeScript will not catch a wrongly shaped config at the JSX boundary. It catches it where you declare the config as Partial<FroalaOptions>, and where you type your own component’s props (see the reusable component section). Put the types there.

How Do You Handle Froala Editor Events in TypeScript?

Events go in the events key of the config. Because the config is typed, each callback’s this is typed as FroalaEditor, so this.html.get() resolves to a real method with a string return type. This is the only reason the example compiles under strict: with an untyped object literal, TypeScript would infer this as the events object and reject this.html.

// src/EventsEditor.tsx
import { useState } from 'react';
import type { FroalaOptions } from 'froala-editor';
import FroalaEditorComponent from './FroalaEditor';

const config: Partial<FroalaOptions> = {
  placeholderText: 'Start writing...',
  events: {
    initialized: function () {
      console.log('Editor ready');
    },
    contentChanged: function () {
      // 'this' is the editor instance, typed as FroalaEditor
      console.log(this.html.get());
    },
    focus: function () {
      console.log('Editor focused');
    },
    blur: function () {
      console.log('Editor lost focus');
    },
  },
};

export default function EventsEditor() {
  const [content, setContent] = useState<string>('');

  return (
    <FroalaEditorComponent
      tag="textarea"
      config={config}
      model={content}
      onModelChange={setContent}
    />
  );
}

Any callback that reads this must be a regular function. Arrow functions capture the surrounding this and cannot be bound to the editor; a callback that ignores this can be either.

contentChanged is the event behind onModelChange, so for React state you already have it. Use it directly when you need something that is not state, such as an autosave timer or validation that should run without a re-render. Froala exposes many more events, including paste and image upload hooks; the events documentation has the full list.

How Do You Access the Editor Instance?

Props and config cover most day-to-day needs. Occasionally you need the instance itself, for example, to run a command from a button outside the editor or read its HTML on demand. The wrapper’s onManualControllerReady prop hands you an object with initialize, destroy, and getEditor, and defers initialization until you call initialize() yourself.

// src/ManualControlEditor.tsx
import { useRef, useState } from 'react';
import type FroalaEditor from 'froala-editor';
import FroalaEditorComponent from './FroalaEditor';

interface ManualControls {
  initialize: () => void;
  destroy: () => void;
  getEditor: () => FroalaEditor | null;
}

export default function ManualControlEditor() {
  const controlsRef = useRef<ManualControls | null>(null);
  const [snapshot, setSnapshot] = useState<string>('');

  const handleControllerReady = (controls: ManualControls) => {
    controlsRef.current = controls;
    controls.initialize();
  };

  const handleGetHtml = () => {
    const editor = controlsRef.current?.getEditor();
    if (!editor) {
      setSnapshot('(editor not mounted)');
      return;
    }
    setSnapshot(editor.html.get());
  };

  return (
    <>
      <div className="button-row">
        <button onClick={handleGetHtml}>Read current HTML</button>
        <button onClick={() => controlsRef.current?.destroy()}>Destroy</button>
        <button onClick={() => controlsRef.current?.initialize()}>Re-initialize</button>
      </div>
      <FroalaEditorComponent
        tag="textarea"
        onManualControllerReady={handleControllerReady}
      />
      <pre>{snapshot || '(no snapshot yet)'}</pre>
    </>
  );
}

import type FroalaEditor from 'froala-editor' is a type-only import. It is erased at build time and gives getEditor a real return type instead of an implicit any. getEditor returns null until the editor has mounted, so guard before calling anything on it. The wrapper does not export a type for the controls object, which is why ManualControls is declared locally.

How Do You Create a Reusable React Editor Component?

Once the pieces work in isolation, wrap them in one typed component the rest of the app can drop in anywhere. Keep the shared config in its own module so the form example and any future editor use the same toolbar.

// src/froala-config.ts
import type { FroalaOptions } from 'froala-editor';

export const baseConfig: Partial<FroalaOptions> = {
  placeholderText: 'Start writing...',
  charCounterCount: true,
  heightMin: 200,
  toolbarButtons: ['bold', 'italic', 'underline', 'insertLink'],
};

 

// src/RichTextEditor.tsx
import FroalaEditorComponent from './FroalaEditor';
import { baseConfig } from './froala-config';

export interface RichTextEditorProps {
  value: string;
  onChange: (value: string) => void;
}

export default function RichTextEditor({ value, onChange }: RichTextEditorProps) {
  return (
    <FroalaEditorComponent
      tag="textarea"
      config={baseConfig}
      model={value}
      onModelChange={onChange}
    />
  );
}

This is where the TypeScript contract becomes real. Any component rendering RichTextEditor must supply a string value and a function that accepts one. A toolbar change or a design update now happens in one file instead of once per screen.

How Do You Use the Editor Inside a React Form?

The reusable component slots into a form like any controlled input. This example checks for empty content before submitting, disables the button while the request is in flight, and surfaces success and failure states.

// src/PostForm.tsx
import { useState, type FormEvent } from 'react';
import RichTextEditor from './RichTextEditor';

// Empty means no text and no embedded media. Parsing with DOMParser decodes
// every entity form (&nbsp;, &#160;, &#xA0;), which a regex on the raw string
// does not.
const isBlank = (html: string) => {
  const body = new DOMParser().parseFromString(html, 'text/html').body;
  const hasMedia = body.querySelector('img, video, iframe, table') !== null;
  return !hasMedia && (body.textContent ?? '').trim().length === 0;
};

export default function PostForm() {
  const [body, setBody] = useState<string>('');
  const [error, setError] = useState<string | null>(null);
  const [status, setStatus] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setError(null);
    setStatus(null);

    if (isBlank(body)) {
      setError('Post content cannot be empty.');
      return;
    }

    setSubmitting(true);
    try {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content: body }),
      });
      if (!res.ok) throw new Error(`Request failed: ${res.status}`);
      setStatus('Published successfully.');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <RichTextEditor value={body} onChange={setBody} />
      {error && <p role="alert">{error}</p>}
      {status && <p role="status">{status}</p>}
      <button type="submit" disabled={submitting}>
        {submitting ? 'Publishing...' : 'Publish'}
      </button>
    </form>
  );
}

The emptiness check parses the HTML instead of running a regex over it. An editor can hold <p>&nbsp;</p> or <p>&#160;</p>, both non-empty as raw strings and both empty as content; textContent decodes every entity form and trim() removes the resulting non-breaking spaces. The media query keeps an image-only post from being rejected as blank. Adjust the selector to whatever your app counts as content. Wrapping fetch in try/catch means a failed request becomes a visible error rather than a silent no-op.

How to Integrate a Rich Text Editor in a React TypeScript Project

 

Common React and TypeScript Integration Issues

The toolbar is missing or unstyled

If the toolbar renders as unstyled buttons, froala_editor.pkgd.min.css did not load. Check that froala-setup.ts is imported from your entry point, not just from one component. If there is no toolbar at all and the CSS is loading, the editor did not initialize: the usual cause is passing onManualControllerReady and never calling initialize(). If the toolbar looks fine but saved content renders without styling elsewhere, the missing file is froala_style.min.css.

A toolbar button appears but does nothing, or does not appear at all

The plugin behind the button is not loaded. insertLink needs the link plugin, charCounterCount needs the char counter plugin, and so on. Either import plugins.pkgd.min.js or the individual plugin file. Listing a button in toolbarButtons does not load its plugin.

TypeScript cannot resolve react-froala-wysiwyg/FroalaEditorView

The wrapper ships its types in lib/index.d.ts, and that file also declares the subpath modules such as react-froala-wysiwyg/FroalaEditorView. TypeScript only loads it when the root package is referenced somewhere in the program. If a file imports only a subpath, add a reference directive at the top of that file:

/// <reference types="react-froala-wysiwyg" />
import FroalaEditorView from 'react-froala-wysiwyg/FroalaEditorView';

Do not fix this with a bare declare module 'react-froala-wysiwyg/FroalaEditorView';. That is a shorthand ambient declaration and makes the import any.

this.html is an error inside an event callback

The config object is untyped. Declare it as Partial<FroalaOptions> and the events map picks up this: FroalaEditor from Froala’s FroalaEvents interface. See the events section above.

Editor content and React state disagree

Content is being set from more than one place, typically model plus a direct call to editor.html.set(). Keep a single flow: the editor updates state through onModelChange, and state flows back through model. If you must set HTML imperatively, do it through state.

The editor initializes twice in development

Older versions of the wrapper rendered two editors under React 18’s StrictMode because StrictMode runs each component’s mount cycle twice in development. Wrapper 5.4 destroys and recreates the editor cleanly across that cycle; we verified a single toolbar and a single .fr-box under StrictMode with React 19. If you see a doubled editor, upgrade react-froala-wysiwyg rather than removing StrictMode, which would hide other lifecycle bugs in your own code.

What Should You Consider Before Using a React Rich Text Editor in Production?

A working demo and a production-ready editor are not the same thing. Before you ship:

  • Sanitize HTML server-side. Froala cleans HTML on the client through htmlAllowedTags, htmlAllowedAttrs, and htmlRemoveTags, and will use DOMPurify if it is present on the page, but client-side cleanup is never a substitute for server-side sanitization of user-submitted content.
  • Set the license key from an environment variable and confirm the editor no longer shows the unlicensed notice on your production domain.
  • Plan the file and image upload workflow: where files land and how they are served back. Froala’s image upload documentation covers the server-side pieces.
  • Import only the plugins you enable, and confirm they are in the production bundle.
  • Lock down toolbarButtons in a shared config so every instance looks and behaves the same.
  • Test keyboard navigation and screen readers against your accessibility requirements.
  • Test mobile and touch behavior, not only desktop layouts.
  • Measure performance with realistic document sizes, not a two-paragraph test.
  • Decide where content persists and what the user sees when a save fails.
  • Keep React, TypeScript, and both Froala packages on current supported versions, and pin them.

Build Your React TypeScript Editor with Froala

Across this tutorial, you installed the editor and its React wrapper and rendered a working editor. Then you connected it to typed React state, added a typed config with events, reached the editor instance when props were not enough, and packaged the result into a reusable component wired into a real form.

The React integration documentation covers the remaining configuration options, events, plugins, and API methods. From there, start a Froala trial and drop the same configuration into your own project.

FAQ

Can I use a rich text editor with React and TypeScript?

Yes. With Froala’s React wrapper, the editor is a controlled component, the config is typed through Partial<FroalaOptions>, event callbacks get a typed this, and the underlying instance is reachable through onManualControllerReady. All of that is shown above.

How do I get HTML from a React rich text editor?

Read the state value that onModelChange keeps up to date, or call this.html.get() inside an event callback, or call editor.html.get() on the instance from getEditor(). Each returns a plain HTML string you can store or send to an API.

Should a React rich text editor be a controlled component?

For reusable components and forms, yes. One flow (onModelChange into state, model back into the editor) is easier to reason about than mixing controlled and uncontrolled patterns on the same editor.

What should I look for in a rich text editor for a React TypeScript project?

An official React wrapper, shipped TypeScript definitions for the editor’s options and events, a documented event system, and a way to reach the underlying instance when props are not enough. An editor that covers those four points will fit a typed codebase without extra glue.

What matters most in a rich text editor for a complex React app?

Plugin architecture and typed configuration matter more than toolbar button count. An editor that exposes custom commands and the underlying instance, with a full event lifecycle, scales better in a large application than one that only covers basic formatting.

What licensing models exist for React rich text editors?

Open-source editors ship under permissive or copyleft licenses and leave maintenance to you. Commercial editors sell licenses on different bases: some per developer seat and some per editor load. Froala is licensed per product and per domain with unlimited users (Professional covers one product on three domains; Enterprise covers unlimited products and domains, including SaaS and OEM use). Check the current terms on the pricing page before committing a production app to any editor.

Are there commercial rich text editors for React that support TypeScript?

Yes. Froala ships type definitions with both the editor and the React wrapper, and several other commercial editors do the same. The trade is a license fee for maintained types and a documented upgrade path, which matters most when the editor is load-bearing in a production system.

Where can I find a customizable React TypeScript rich text editor package?

Start with the vendor’s GitHub repository and the npm package page. The plugin system and the TypeScript types should be documented in one place, which is what this tutorial walked through for Froala.

What are typical pricing plans for React rich text editors?

Open-source editors are free to use and yours to maintain, and some vendors charge per editor load on top of a free tier. Froala is a flat annual or perpetual license per product and domain allowance, with no per-user or per-load charge, and an Enterprise tier for SaaS and OEM products. Confirm current pricing on the vendor’s page rather than relying on older guides.

 

graphical user interface, text

Posted on September 21, 2026

Shamal Jayawardhana

Shamal Jayawardhana is a seasoned web development expert and technical content strategist with a proven track record of helping developers and digital creators thrive. With over five years of hands-on experience, he has worked with leading SaaS brands to produce high-impact tutorials, WordPress guides, and developer-focused resources.

No comment yet, add your voice below!


Add a Comment

Your email address will not be published. Required fields are marked *