React45 minCoursera

Text Transformer

Prompt

Build a configurable text transformer in React. The user types text into an input field and sees the transformed result below it in real time. Transformations are defined as an array of operations passed via props - the component applies them in sequence, like a pipeline. Support uppercase, lowercase, capitalize vowels, and character replacement.

Requirements

  • Render a text input and display the transformed output below it
  • Accept a transformations prop - an array of operation objects
  • Support four operations: uppercase, lowercase, capitalize-vowels, and replace
  • Apply operations in order (pipeline - the output of one feeds into the next)
  • The replace operation takes a from and to field in its config

Example

Configurable Text Transformer

Playground

Hint 1

The transformations prop is an array of objects. Each object has a type field and optionally other fields like from and to. How would you loop through this array and apply each operation to the text one after another, passing the result of one into the next?

Hint 2

You need one piece of state - the input text. The transformed output is derived from that state plus the config. Should you store the output in state too, or can you compute it during render?

Hint 3

For capitalize-vowels, you need to check each character against a set of vowels. What data structure gives you the fastest lookup? And remember - the input might have uppercase vowels already, so your check should be case-insensitive.

Solution

Explanation

Project Structure

We split the code into focused files:

  • App.js holds the transformation config and renders TextTransformer
  • utils/transform.js contains the pure transformation logic - applyOperation and transformText
  • components/TextTransformer.js manages input state, calls the transform utility, and renders the UI

The config lives in App.js because it is static data. The transformation logic lives in a utility file because it is pure - it takes text and config, returns text, with no React dependency. This makes it easy to test and reuse. The component just wires everything together.

The Config Shape

Each operation is a plain object with a type field:

const TRANSFORMATIONS = [
{ type: 'lowercase' },
{ type: 'capitalize-vowels' },
{ type: 'replace', from: 'e', to: '3' },
];

This is an array on purpose. The order matters - operations are applied in sequence, like a pipeline. If you lowercase first and then capitalize vowels, you get a different result than capitalizing vowels first and then lowercasing.

The replace operation has extra fields (from and to) because it needs to know what to find and what to substitute. The other operations are self-contained.

Why an array instead of a single config object?

You could design this as { uppercase: true, capitalizeVowels: true } - but then you lose ordering. With an object, you have no control over which operation runs first. An array makes the pipeline explicit: position 0 runs first, position 1 runs second, and so on. It also makes it trivial to add or remove operations without restructuring anything.

The Transform Pipeline

The core of the component is the transformText function. It uses reduce to pass the text through each operation one at a time:

function transformText(text, transformations) {
if (!text || transformations.length === 0) {
return text;
}

return transformations.reduce(
(result, operation) =>
applyOperation(result, operation),
text
);
}

The early return at the top catches two cases upfront: empty input (nothing to transform) and empty config (nothing to do). After that, reduce starts with the original text as the accumulator and feeds it through each operation.

This is a common pattern called a pipeline. Each step takes the output of the previous step as input. If you have [lowercase, replace, capitalize-vowels], the text flows through like this:

"Hello World" → lowercase → "hello world" → replace e→3 → "h3llo world" → capitalize-vowels → "h3llO wOrld"

Notice the order matters. The replace runs on lowercase text, so it finds e and swaps it to 3. Then capitalize-vowels runs on the remaining vowels. If you swapped the last two steps, capitalize-vowels would turn e into E first, and then replace would look for lowercase e and find nothing.

The Operations

Each operation is handled by a pure function with a switch statement:

function applyOperation(text, operation) {
switch (operation.type) {
case 'uppercase':
return text.toUpperCase();

case 'lowercase':
return text.toLowerCase();

case 'capitalize-vowels':
return text
.split('')
.map((ch) =>
VOWELS.has(ch.toLowerCase())
? ch.toUpperCase()
: ch
)
.join('');

case 'replace': {
if (!operation.from) return text;
return text.replaceAll(
operation.from,
operation.to ?? ''
);
}

default:
return text;
}
}

A few things worth noting:

capitalize-vowels uses ch.toLowerCase() before checking the Set. This makes it case-insensitive - if the input already has an uppercase A, we still recognize it as a vowel.

replace guards against a missing from field. If someone passes { type: 'replace' } without specifying what to replace, we just return the text unchanged. The ?? '' on to handles the case where to is undefined - it defaults to replacing with nothing (effectively deleting the character).

default returns the text unchanged. If someone passes an unknown operation type, the pipeline does not break - it just skips that step and moves on.

Why a Set for Vowels

const VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);

A Set gives O(1) lookup with .has(). For 5 vowels it honestly does not matter performance-wise - a string like 'aeiou'.includes(ch) works just as well. But using a Set is the more intentional choice and shows the interviewer you think about data structures even for small things.

useMemo for the transformed output?

In our solution, transformText runs on every render. For a simple text input this is perfectly fine - string operations on short text are nearly instant. If the transformation config were expensive to compute or the text were very long, you could wrap it in useMemo:

const output = useMemo(
() => transformText(text, transformations),
[text, transformations]
);

In an interview, start without it. But don't wait for the interviewer to ask - mention that you intentionally skipped useMemo because the computation is cheap here, and that you would reach for it only when there is an actual performance issue to solve. This shows you know about optimization but also know when not to use it.

Accessibility

The output has aria-live="polite" so screen readers announce changes as the user types:

<div className="output" aria-live="polite">
{output}
</div>

The input has a proper <label> linked via htmlFor/id. Small things, but interviewers notice when you do them without being asked.

A common mistake is to store the transformed output in state alongside the input text. You do not need two pieces of state here. The output is fully derived from the input + config - computing it during render is simpler and avoids the risk of the two values getting out of sync.

What if the interviewer asks you to transform in place?

A common follow-up is "can you show the transformed text inside the input itself instead of a separate output?" The pipeline logic stays exactly the same - what changes is where the result goes. Instead of rendering it in a div, you set the input's value to the transformed text directly. Try it out in the playground below.

For our transformations this works out of the box because none of them change the string length - lowercase, capitalize-vowels, and single-char replace all produce the same number of characters, so the browser keeps the cursor where you'd expect.

But if a transformation changes the length - say replacing "e" with "33", or stripping characters entirely - the cursor will jump to the end after each keystroke. In that case you'd need to save and restore the cursor position manually:

const inputRef = useRef(null);
const cursorRef = useRef(null);

function handleChange(e) {
cursorRef.current = e.target.selectionStart;
setText(e.target.value);
}

useEffect(() => {
if (cursorRef.current !== null) {
inputRef.current?.setSelectionRange(
cursorRef.current,
cursorRef.current
);
cursorRef.current = null;
}
}, [output]);

Worth mentioning in the interview even if you don't need it for the given config - it shows you're thinking about what happens when a controlled input's value diverges from what the user typed.

Interviewer Criteria

HTML & CSS

  • Input and output are clearly separated with labels identifying each section.

  • CSS variables used for shared colors like border, focus, and text.

  • Input and output share base styles (border, padding, font size) for visual consistency.

JavaScript

  • Transformations applied in sequence using reduce (pipeline pattern).

  • capitalize-vowels uses case-insensitive check (ch.toLowerCase() before lookup).

  • Vowel lookup uses a Set instead of an array.

  • replaceAll used instead of replace to handle multiple occurrences of the same character.

  • Handled edge case: empty input returns the text unchanged without running the pipeline.

  • Handled edge case: empty transformations array returns the text unchanged.

  • Handled edge case: replace with missing from field returns the text unchanged.

  • Handled edge case: replace with missing to field defaults to empty string (deletion).

  • Handled edge case: unknown operation type in default branch returns text unchanged.

React

  • Single piece of state (input text). Transformed output is derived, not stored in state.

  • Transformations config passed as a prop, making the component reusable with any config.

  • Controlled input with value and onChange.

  • Transform logic extracted to a utility file with no React dependency, keeping components focused on rendering.

Accessibility

  • Input has a label element linked via htmlFor and id.

  • Output area uses aria-live="polite" so screen readers announce changes.

Code Quality

  • applyOperation is a pure function - takes text and operation, returns new text with no side effects.

  • Config shape is extensible - adding a new operation only requires a new case in the switch statement.

  • Clean separation: config in App.js, pure transform logic in utils/transform.js, state and rendering in TextTransformer.js.

  • Variable names clearly communicate intent: transformText, applyOperation, VOWELS, output.

Time Checkpoints

  • 10:00 AM

    Interview starts šŸ‘„

  • 10:03 AM

    Prompt given by the interviewer

  • 10:05 AM

    Candidate reads the prompt, asks clarifying questions, and starts coding

  • 10:12 AM

    Controlled input field with state, basic layout with labels and output area

  • 10:20 AM

    transformText function with reduce pipeline and applyOperation switch

  • 10:28 AM

    Implement all four operations: uppercase, lowercase, capitalize-vowels, replace

  • 10:34 AM

    Edge cases: empty input, empty config, missing from/to, unknown operation type

  • 10:38 AM

    Extract TextTransformer component, pass config as prop

  • 10:42 AM

    Polish: accessibility attributes, labels, aria-live on output

  • 10:45 AM

    Discussions with interviewer

  • 10:45 AM

    Interview ends āœ