An interactive React application offering real-time analysis of user input.


This project is the first React application I built using the concept of state. It provides real-time analysis of user input, including character count, word count, sentence count, estimated reading time, and character density.

Project Requirements

  • Users should be able to input text and select checkbox options to receive an analysis of the text
  • Live Text Analysis: Updates character, word, sentence count, and character density with each keystroke
  • Responsive to User Options: Reflects checkbox options like excluding spaces and setting character limits in the analysis
  • Character Density Visualization: Displays density as progress bars for each character
  • Immediate Feedback: Shows alert messages when the character limit is exceeded
  • Dark/Light Mode Toggle: Allows users to switch between themes via an icon

Tech Stack

  • Framework: React 19.0.0
  • Build Tool: Vite 6.2.0
  • Styling: CSS
  • Deployment: GitHub Pages

Directory Structure

src
├─ App.css
├─ App.jsx
├─ assets
├─ components
│  ├─ BannerList/
│  ├─ DensityList/
│  ├─ Header/
│  ├─ Main/
│  ├─ Options/
│  ├─ ReadingTime/
│  └─ TextInput/
├─ context
│  └─ TextContext.jsx
├─ index.css
├─ index.jsx
├─ locales
│  └─ en.json
└─ utils
   └─ formatText.js

The components are nested as follows:

<Header> // Logo, theme toggle, 'isDark' state
<Main> // 'content' state
  <TextInput /> // Input field
  <Options /> // Exclude spaces, character limit checkboxes
  <ReadingTime /> // Estimated reading time
  <BannerList /> // Character, word, sentence count
  <DensityList /> // Character density


State Management

State Management Principles

  • Colocation: States are declared in the components where they’re used
  • State Lifting: Shared states are lifted to the nearest common parent
  • Minimal Context: Context API was not necessary for state management, as state lifting was sufficient
  • Minimal State: Only essential states are declared; derived values (e.g. character counts) are calculated from existing state rather than stored separately

States in the Project

All states are managed locally using useState.

  1. isDark

    • Tracks current theme
    • Type: Boolean
    • Declared in:
    • Used in:
  2. content

    • Stores user input and option selections
    • Type: Object
    • Declared in:
    • Used in:
      and its children
  3. isOpen

    • Controls accordion toggle state
    • Type: Boolean
    • Declared in:
    • Used in:

‎ ‎

Key Feature Implementations

Real-Time Input Handling

User input and checkbox values are synced to the content state using event handlers. The content object includes properties like userinput, nospace, limit, and maxlength. All inputs are controlled, meaning their values are tied directly to the state and updated dynamically.

User input → Updates content → Inputs receive value/checked from content

Why Use Controlled Inputs

Using controlled inputs makes sense when:

  • Input values affect other parts of the UI
  • You need to validate or modify user input
  • Input values can be changed programmatically (e.g. resetting fields)

In this project, all inputs are controlled to ensure consistency and allow future scalability—such as trimming characters that exceed the set limit.

Live Text Analysis Based on User Options

Every time the user types or toggles an option, the app re-renders and recalculates the results. There’s no separate state for analysis data—everything is computed on the fly using utility functions based on the current content.

Logic for Character, Word, and Sentence Counts

function noSpaceTotalChars(text) {
  return text.split(" ").join("").length;
}

function countWord(text) {
  const wordList = text.split(" ").filter((item) => item !== "");
  return wordList.length;
}

function countSentence(text) {
  const sentenceList = text
    .split(/[.?!]/) // Split by ".", "?", or "!"
    .map((item) => item.trim())
    .filter((item) => item !== "");
  return sentenceList.length;
}

Logic for Letter Density

function getSortedDensity(text, minThreshold, ignoreCase, displayUpper) {
  const clearedText = removeSpecialCharsSpaces(text);
  const densityCount = getDensity(clearedText, ignoreCase, displayUpper);
  densityCount.sort((a, b) => b[2] - a[2]);
  const rangedDensity = densityCount.filter((item) => item[2] >= minThreshold);
  return rangedDensity;
  // Returns [['char', count, density], ...]
}

Rendering Results

and components pass the analysis data to their child components ( and ) and render them as JSX arrays.

Immediate Feedback When Character Limit is Exceeded

If the user input goes beyond the defined character limit, the input field’s border changes color, and a warning message is displayed.

This limit check isn’t stored as a separate piece of state—it’s derived directly from the existing content state on every render. The computed value is stored in the isLimitReached variable, which is shared between the

component (for conditionally rendering the alert message) and (for applying visual styles to the input field).

Message Rendering Logic

const isLimitReached =
  content.userinput &&
  content.maxlength &&
  content.userinput.length > content.maxlength;

return (
  <main>
    // ...
    <TextInput
      content={content}
      setContent={setContent}
      isLimitReached={isLimitReached}
    />
    {isLimitReached && <p className="limit-alert">{limitAlert}p>}
    // ...
  main>
);

Accordion Toggle for Density List

To keep the UI clean and uncluttered, only the top 5 character densities are displayed by default. The rest are tucked away in an accordion component that the user can expand or collapse.

  • Shows the top 5 characters by default
  • Clicking “See more” reveals the full list
  • Clicking “See less” collapses it back

The open/closed state of the accordion is managed using the isOpen state. Whether the list needs to be split—and which button label to show—is determined dynamically based on the total number of items and the isOpen state.

Accordion Logic

const shouldSplit = densityList.length > 5;
const [isOpen, setIsOpen] = useState(false);
const [firstList, secondList] = cutList(densityList, 5);
const handleClick = () => {
  setIsOpen((prev) => !prev);
};

  return (
    <div>
      <div>
        {firstList}
        {isOpen && secondList}
      div>
      {shouldSplit && (
        <p onClick={handleClick}>
          {isOpen
            ? text.density_list_close_label
            : text.density_list_open_label}
        p>
      )}
    div>
  );
}

Dark/Light Mode Toggle

The app’s theme is controlled by the isDark state. Based on its value, a corresponding class (dark-mode or light-mode) is applied to the , which handles global styling.

Clicking the theme toggle icon updates isDark, triggering a re-render and applying the new theme class.

Why Context Wasn’t Used for Theme Toggling

Since isDark is only used at the top level and doesn’t need to be accessed across many components, there’s no need for React Context. Applying the theme class directly to avoids prop drilling or introducing unnecessary context logic, keeping the solution simple and scalable for an app of this size.

Localization Setup

This project implements a scalable structure that allows for easy future expansion into multiple languages without using a full i18n library.

UI Text in JSON

All display text is stored in locales/en.json. To add more languages, you can simply create files like locales/ko.json. The setup supports dynamic values (e.g., {reading_time_value}) and basic pluralization logic.

{
  "app_title": "Analyze your text in real-time.",
  "input_placeholder": "Start typing here (or paste your text)",
  "option_title_excl_space": "Exclude Spaces",
  "option_title_char_limit": "Set Character Limit",
  "reading_time_display": "Approx. reading time: {reading_time_value}",
  "reading_time_value": {
    "none": "0 minute",
    "one": "< 1 minute",
    "other": "< %d minutes"
  },

Text Formatting Utility

A custom utility function handles placeholder substitution and pluralization logic:

export function formatText(template, values, pluralRules = {}) {
  return template.replace(/\{(\w+)\}/g, (_, key) => {
    if (pluralRules[key]) {
      if (!values[key]) {
        return pluralRules[key].none;
      } else {
        return values[key] === 1
          ? pluralRules[key].one
          : pluralRules[key].other.replace("%d", values[key]);
      }
    }
    return values[key] ?? `{${key}}`;
    // Default to keeping the placeholder if missing
  });
}

Passing UI Text Across the App with Context

A TextContext is used to provide UI text throughout the app, allowing all components to access text content without prop drilling. Since UI text is typically fixed at release and rarely changes during a single session, using context does not introduce unnecessary re-renders.

This approach also improves maintainability by centralizing the management of UI text. It enables easier updates and scalability, such as supporting additional languages, without needing to change the component structure. Language support can be extended by enhancing how the app loads and manages language data.

Continued Development

  • Use rem unit so the UI scales with the browser default font size
  • Persist the user’s theme preference using localStorage

Resources

Author

Nimus-oes, a frontend engineer & localization project manager.

This project is my solution to the Character Counter challenge from FrontendMentor.