featured image

Hard-Coded Prompts Will Betray You: Building a Composable, Context-Aware Prompt Pipeline

A step-by-step walkthrough of how to design and implement a composable prompt system for an on-device LLM-powered React Native app, using the Strategy pattern to separate prompt construction from UI logic and ensure testability and maintainability as requirements grow.

Published

Sat Nov 01 2025

Technologies Used

React Native LLM
Intermediate 24 minutes

Your first LLM-powered feature starts with a string constant at the top of the chat component. It works. Then the product needs topic-specific coaching modes. You add an if/else. Then time-of-day awareness. Another if/else. Then emotional state tracking. Now there’s a 200-line prompt assembly function in the middle of a UI file with business logic scattered everywhere, untestable without rendering a screen, and impossible to extend without breaking something.

I hit this exact wall building the mindfulness coach app. The fix was a two-layer prompt pipeline: services/llm/PromptBuilder.ts — a pure service class that knows how to build prompts — and hooks/useChat.ts — a React hook that knows when to build them and with what context. Together they implement the Strategy pattern: the prompt construction algorithm is completely decoupled from the code that decides which prompt to build.

What to Know Before Wiring Strategy Patterns Into Hooks

Knowledge:

  • The Strategy design pattern (algorithm family, encapsulated, interchangeable)
  • React custom hooks and the rules of hooks
  • TypeScript interfaces and optional properties
  • How LLMs use system prompts

Environment:

react             19.1.0
react-native      0.81.5
typescript        ~5.9.2
expo              ~54.0.22
uuid              ^13.0.0

How Context Travels From a Button Tap to a System Prompt

Think of PromptBuilder as a chef working mise en place. The BASE_SYSTEM_PROMPT in constants/prompts.ts is the stock — always on the stove, always the foundation. PromptOptions are the additional ingredients the server brings from the dining room based on what the user ordered. buildSystemPrompt() is the chef combining everything to order. The dining room (the React component) never touches the stove.

The data flow:

graph LR
    U[User taps QuickAction] --> UC[useChat.sendQuickAction]
    UC --> PO[PromptOptions assembled in useChat]
    PO --> PB[PromptBuilder.buildSystemPrompt]
    PB --> BP[BASE_SYSTEM_PROMPT]
    PB --> TP[Topic-specific addition]
    PB --> CTX[UserContext addition]
    BP & TP & CTX --> SP[Final system prompt string]
    SP --> IO[InferenceOptions]
    IO --> GR[generateResponse]
    GR --> LLM[On-device Gemma model]

Building the Pipeline Layer by Layer

The Foundation: A Typed Constant as the Coach’s Identity

Everything starts in constants/prompts.ts. The base prompt isn’t assembled at runtime — it’s a fixed identity for the coaching persona.

export const BASE_SYSTEM_PROMPT = `You are a compassionate mindfulness coach 
who draws wisdom from both Buddhist and Stoic philosophical traditions...

Core Principles:
- Buddhist Perspective: Emphasize mindfulness, compassion, impermanence
- Stoic Perspective: Focus on virtue, rational thinking, acceptance

Response Style:
- Keep responses concise but meaningful (2-4 paragraphs typically)
- Use questions to encourage reflection
- Maintain a calm, grounded tone`;

// Topic prompts are keyed to a union type, not a plain string.
// TypeScript will error if you access a key that doesn't exist.
export const TOPIC_PROMPTS: Record<MindfulnessTopic, string> = {
  [MindfulnessTopic.Anxiety]: `The user is experiencing anxiety...
    - Buddhist: Mindfulness of breath, impermanence of feelings
    - Stoic: Distinguishing between what we control and don't control`,
  // ... other topics
};

Using Record<MindfulnessTopic, string> instead of Record<string, string> means TypeScript will catch it at compile time if you add a new topic enum value and forget to add a corresponding prompt. That’s the kind of error that would otherwise surface at 2am when a user triggers the unhandled case.

The Builder: Composing Prompt Layers Without if/else Hell

The naive approach adds conditionals directly inside generateResponse. The refined approach extracts composition into a dedicated class with a single method:

export class PromptBuilder implements PromptBuilderInterface {
  
  buildSystemPrompt(options?: PromptOptions): string {
    let prompt = BASE_SYSTEM_PROMPT;

    if (!options) return prompt;

    // Additive composition — each modifier appends rather than replaces
    if (options.emphasizeBuddhism && !options.emphasizeStoicism) {
      prompt += '\n\nFor this conversation, place extra emphasis on Buddhist teachings.';
    } else if (options.emphasizeStoicism && !options.emphasizeBuddhism) {
      prompt += '\n\nFor this conversation, place extra emphasis on Stoic philosophy.';
    }

    if (options.userContext) {
      const contextAddition = this.buildUserContextPrompt(options.userContext);
      if (contextAddition) {
        prompt += '\n\n' + contextAddition;
      }
    }

    if (options.conversationGoal) {
      prompt += `\n\nConversation Goal: ${options.conversationGoal}`;
    }

    return prompt;
  }

The design is additive — each modifier appends to the base prompt rather than replacing it. This means you can add a new PromptOptions field and a new if block without touching any existing logic. The base prompt always runs. Topic emphasis only runs if requested. User context only runs if provided.

The Hook: Assembling PromptOptions from Runtime State

useChat is where React state meets the PromptBuilder. It translates component-level concerns (which topic is active, what the user sent) into PromptOptions:

export function useChat(options?: UseChatOptions): UseChatReturn {
  const [promptOptions, setPromptOptions] = useState<PromptOptions>(
    options?.promptOptions || {}
  );

  const sendMessage = useCallback(async (content: string) => {
    // Build fresh for each message — topic changes are reflected immediately
    const systemPrompt = promptBuilder.buildSystemPrompt(promptOptions);

    const inferenceOptions: InferenceOptions = {
      systemPrompt,
      maxTokens: inferenceOptions.maxTokens,
      temperature: inferenceOptions.temperature,
    };

    await llmContext.generateResponse(messages, inferenceOptions, onToken);
  }, [promptOptions, messages]);

  const sendQuickAction = useCallback(async (action: QuickAction) => {
    const actionPrompt = promptBuilder.getQuickActionPrompt(action);
    await sendMessage(actionPrompt);
  }, [sendMessage]);

  const setTopic = useCallback((topic: MindfulnessTopic) => {
    const topicAddition = promptBuilder.addTopicEmphasis(topic);
    setPromptOptions(prev => ({
      ...prev,
      conversationGoal: topicAddition,
    }));
  }, []);

promptOptions is stateful — it can change during a session when the user switches topics or sends quick actions. The system prompt is built fresh for each message, so a topic change mid-conversation is reflected in the very next response. There’s no stale prompt to worry about.

Why Pure Functions Make LLMs Predictable

PromptBuilder.buildSystemPrompt() is a pure function with respect to its inputs: given the same PromptOptions, it always returns the same string. This has a practical payoff — you can write unit tests for every coaching scenario without mocking a React component, starting an LLM, or simulating user interaction:

it('adds Buddhist emphasis when emphasizeBuddhism is true', () => {
  const result = promptBuilder.buildSystemPrompt({ emphasizeBuddhism: true });
  expect(result).toContain('Buddhist teachings');
  expect(result).not.toContain('Stoic philosophy');
});

That test runs in milliseconds, no native modules needed. I can run the full prompt test suite before every commit.

The separation also means prompt changes don’t cause React re-renders. PromptBuilder is a singleton outside the React tree. Changing the base prompt constant in constants/prompts.ts affects every subsequent buildSystemPrompt() call without touching any component. Compare this to storing the system prompt in React state — every prompt update triggers a render across every subscribed component.

Where This Architecture Can Fail

Stale promptOptions between sessions. useChat receives sessionId as a prop. When the user navigates from one session to another, the hook re-mounts with new options. If promptOptions is initialized from options?.promptOptions at mount time, it correctly resets for the new session. The risk: if a developer passes promptOptions as an object literal inline — <Chat promptOptions={{ emphasizeBuddhism: true }} /> — React creates a new object reference on every render, which can cause subtle re-initialization loops. The correct pattern is to memoize or hoist promptOptions outside the render function.

Silent failure from conflicting emphasis flags. The buildSystemPrompt method handles emphasizeBuddhism && emphasizeStoicism (both true) by falling through to the else if — meaning neither emphasis is added, silently. There’s no error, no warning. If a caller sets both flags, the system prompt behaves as if neither was set. A more defensive implementation would log a warning. As you extend PromptOptions, document mutual-exclusion constraints explicitly in the interface definition.

Prompt injection via user-controlled context. buildUserContextPrompt() inserts context.emotionalState directly into the system prompt string. If this value ever comes from raw user input rather than a controlled enum, a user could craft an emotional state string that overrides coaching behavior. The current implementation uses controlled values, but this should be enforced at the type level with a union type rather than string. Never interpolate unvalidated user input into a system prompt.

The Strategy + Builder pattern for LLM prompts is one of the cleanest ways I’ve found to keep AI features maintainable. Prompt construction is independently testable. UI logic stays in hooks and components. Adding a new coaching mode means a new constant, a new PromptOptions field, and a new if block — not surgery on a 200-line string assembly function.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!