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. Support uppercase, lowercase, capitalize vowels, and character replacement.
Requirements
- Render a text input and display the transformed output below it
- Accept a prop which is an array of operation objects
- Support four operations:
uppercase,lowercase,capitalize-vowels, andreplace - Apply operations in order
- The
replaceoperation takes afromandtofield in its config - The
capitalize-vowelsoperation should be case-insensitive
Example

Playground
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?
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?
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.jsholds the transformation config and rendersTextTransformerutils/transform.jscontains the pure transformation logic:applyOperationandtransformTextcomponents/TextTransformer.jsmanages 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, meaning it takes text and config, returns text, with no React dependency. This makes it easy to test and reuse. The component connects all the pieces together, holding the input state, calling the transform utility with that state, and rendering both the input field and the output.
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, because 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, uppercase, lowercase, and capitalize-vowels, only need the type field. The type name alone is enough to tell the function what to do. There is nothing extra to configure because the behavior is always the same: uppercase always makes every letter uppercase, lowercase always makes every letter lowercase, and capitalize-vowels always capitalizes every vowel.
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 very easy to add or remove operations, you just add or remove an object from the array, without changing how the rest of the code is structured.
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 initial value, also called the accumulator, which is the running result that gets updated after each step, and passes it through each operation one by one.
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 applyOperation, a pure function, using a switch statement to decide which transformation to apply:
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, so 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.
What is a pure function?
A pure function is a function that follows two rules:
- Same input always gives the same output. If you call
applyOperation("hello", { type: "uppercase" })ten times, you will always get"HELLO". The result never changes based on time, external variables, or any hidden state. - It does not change anything outside itself. It does not update a variable declared outside the function, does not write to a database, does not modify the DOM, and does not produce any other side effect. It takes its inputs, does its work, and returns a new value.
This matters because pure functions are predictable and easy to test. You can test applyOperation by passing in a string and an operation and checking what comes back, with no setup, no teardown, and no mocking.
Why a Set for Vowels
const VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);A Set gives O(1) lookup with .has(), meaning it finds the answer in a fixed, constant amount of time no matter how large the Set is. For only 5 vowels it does not matter performance-wise. A string like 'aeiou'.includes(ch) works just as well. But using a Set shows the interviewer that you are aware of data structures and are deliberately choosing the right one for the job, even when the input is small.
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 work being done here is fast. Transforming a short string with a few string methods runs in microseconds, so there is no real performance problem 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. These are small details, but interviewers notice when you add 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 and 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 without any extra code because none of them change the length of the string. Lowercase, capitalize-vowels, and single-character replace all produce the same number of characters as the input. When the length stays the same, the browser can keep the text cursor (the blinking line that shows where you are typing) at the same position after each keystroke.
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 are thinking about what happens when a controlled input's displayed value becomes different from what the user originally typed, because a transformation changed the text.
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 that 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:43 AM
Discussions with interviewer
- 10:45 AM
Interview ends ā