UTA DevHub

Component State Management

Guide for managing local component state with React's built-in state management

Component State Management

Overview

This document outlines our approach to managing component-level state in our React Native application. Component state represents local state that is specific to a single component or a small component tree. We implement React's built-in state management hooks (useState and useReducer) for managing ephemeral UI state, form data, and other localized concerns. This follows our application's Golden Rule: "Component state belongs in React's built-in hooks, not in Zustand or TanStack Query". This approach optimizes for simplicity, performance, and proper separation of concerns.

Purpose & Scope

  • Target Audience: React Native developers implementing UI components that require local state management
  • Problems Addressed: Proper state isolation, form management, UI interactions, and temporary state that doesn't belong in global stores
  • Scope Boundaries: Covers React's built-in state management hooks, common patterns, performance optimization, and custom hooks, but does not cover global state or server data management

Core Components/Architecture

When to Use Component State

Use React's built-in state management for:

  • Form inputs before submission
  • UI toggles (dropdowns, modals specific to one component)
  • Temporary values during user interaction
  • Animation states
  • Local component configuration
  • Any state that doesn't need to be shared beyond a small component tree

Basic Component State

Simple State with useState

The useState hook is ideal for managing simple, independent state values in a component.

import React, { useState } from 'react';
import { View, TextInput, Button, Text } from 'react-native';
 
const ContactForm: React.FC = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [message, setMessage] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleSubmit = async () => {
    setIsSubmitting(true);
    try {
      await submitContact({ name, email, message });
      // Reset form
      setName('');
      setEmail('');
      setMessage('');
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return (
    <View>
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Name"
      />
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        keyboardType="email-address"
      />
      <TextInput
        value={message}
        onChangeText={setMessage}
        placeholder="Message"
        multiline
      />
      <Button
        title="Submit"
        onPress={handleSubmit}
        disabled={isSubmitting}
      />
    </View>
  );
};

When to use useState:

  • For independent state variables
  • When state logic is simple
  • For primitive values or simple objects
  • When you don't need derived state

Temporary UI State

State that's only needed during specific user interactions like loading, confirmation, or error states.

const ListItem: React.FC<{ item: Item; onDelete: (id: string) => void }> = ({
  item,
  onDelete,
}) => {
  const [isDeleting, setIsDeleting] = useState(false);
  
  const handleDelete = async () => {
    setIsDeleting(true);
    try {
      await onDelete(item.id);
    } catch (error) {
      setIsDeleting(false);
      // Handle error
    }
  };
  
  return (
    <View style={[styles.item, isDeleting && styles.deleting]}>
      <Text>{item.name}</Text>
      <Button
        title="Delete"
        onPress={handleDelete}
        disabled={isDeleting}
      />
    </View>
  );
};

Common Temporary State Types:

  • Loading indicators
  • Validation states
  • Confirmation dialogs
  • Error messages
  • Success notifications

Common Patterns

Controlled Components

Controlled components are React components where form data is handled by the component's state.

import React, { useState, useEffect } from 'react';
import { View, TextInput, Text } from 'react-native';
 
const SearchBar: React.FC = () => {
  const [searchTerm, setSearchTerm] = useState('');
  const [suggestions, setSuggestions] = useState<string[]>([]);
  
  useEffect(() => {
    // Debounce logic could be added here
    if (searchTerm.length > 2) {
      fetchSuggestions(searchTerm).then(setSuggestions);
    } else {
      setSuggestions([]);
    }
  }, [searchTerm]);
  
  return (
    <View>
      <TextInput
        value={searchTerm}
        onChangeText={setSearchTerm}
        placeholder="Search..."
      />
      {suggestions.length > 0 && (
        <View>
          {suggestions.map((suggestion) => (
            <Text key={suggestion}>{suggestion}</Text>
          ))}
        </View>
      )}
    </View>
  );
};

Benefits:

  • Predictable flow of data
  • Single source of truth
  • Easier to validate or transform input
  • Enables immediate UI responses to user input

Collapsible UI Elements

Toggle-based UI elements like accordions, expandable panels, and dropdown menus are common patterns that use local state.

import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
 
const Accordion: React.FC<{ title: string; children: React.ReactNode }> = ({
  title,
  children,
}) => {
  const [isExpanded, setIsExpanded] = useState(false);
  
  const toggleExpand = () => setIsExpanded(!isExpanded);
  
  return (
    <View style={styles.container}>
      <TouchableOpacity onPress={toggleExpand}>
        <View style={styles.header}>
          <Text style={styles.title}>{title}</Text>
          <Text>{isExpanded ? '−' : '+'}</Text>
        </View>
      </TouchableOpacity>
      {isExpanded && (
        <View style={styles.content}>
          {children}
        </View>
      )}
    </View>
  );
};
 
const styles = StyleSheet.create({
  container: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 4,
    marginBottom: 10,
  },
  header: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    padding: 15,
    backgroundColor: '#f5f5f5',
  },
  title: {
    fontWeight: 'bold',
  },
  content: {
    padding: 15,
  },
});

Implementation Notes:

  • Keep toggle state close to where it's used
  • For multiple accordion items, consider an array of expanded states or IDs
  • For complex cases, useReducer may be more appropriate

Custom Hooks for Reusable Component State

Toggle State Hook

A simple hook for boolean toggle state that's commonly used in UI components.

import { useState, useCallback } from 'react';
 
/**
 * A hook for managing boolean toggle state with a single function
 * 
 * @param initialValue - Initial boolean state value
 * @returns [value, toggleFunction]
 */
const useToggle = (initialValue: boolean = false): [boolean, () => void] => {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle];
};

Usage Example

const ExpandablePanel: React.FC<{ title: string; children: React.ReactNode }> = ({
  title,
  children
}) => {
  const [isExpanded, toggleExpanded] = useToggle(false);
  
  return (
    <View style={styles.container}>
      <TouchableOpacity onPress={toggleExpanded}>
        <View style={styles.header}>
          <Text>{title}</Text>
          <Text>{isExpanded ? '−' : '+'}</Text>
        </View>
      </TouchableOpacity>
      {isExpanded && (
        <View style={styles.content}>{children}</View>
      )}
    </View>
  );
};

When to Elevate State

Multiple Components Need Access

When state needs to be shared across components that aren't directly related, you should elevate to global state.

// ❌ Prop drilling through multiple components
const App = () => {
  const [user, setUser] = useState(null);
  return (
    <Layout user={user}>
      <MainContent user={user}>
        <Sidebar user={user} />
        <Feed user={user} />
      </MainContent>
      <Footer user={user} />
    </Layout>
  );
};
 
// ✅ Shared state with Zustand
const useUserStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user })
}));
 
const UserAvatar = () => {
  const user = useUserStore((state) => state.user);
  return <Avatar user={user} />;
};
 
const UserSettings = () => {
  const user = useUserStore((state) => state.user);
  return <Settings user={user} />;
};

Persistence Across Navigation

When state needs to survive component unmounting or navigation events.

// ❌ Lost state on navigation
const ProfileScreen = () => {
  const [filters, setFilters] = useState({ sortBy: 'date', showArchived: false });
  // State is lost when user navigates away
  
  return <UserList filters={filters} onFilterChange={setFilters} />;
};
 
// ✅ Persistent state with Zustand
const useFiltersStore = create((set) => ({
  filters: { sortBy: 'date', showArchived: false },
  setFilters: (filters) => set({ filters })
}));
 
const ProfileScreen = () => {
  const { filters, setFilters } = useFiltersStore();
  // State persists even if user navigates away
  
  return <UserList filters={filters} onFilterChange={setFilters} />;
};

Performance Considerations

1. Minimize Re-renders

// ❌ Bad: New object on every render
const Component: React.FC = () => {
  const [form, setForm] = useState({ name: '', email: '' });
  
  return (
    <ChildComponent 
      config={{ form, extra: 'data' }} // New object every render
    />
  );
};
 
// ✅ Good: Stable references
const Component: React.FC = () => {
  const [form, setForm] = useState({ name: '', email: '' });
  const config = useMemo(
    () => ({ form, extra: 'data' }),
    [form]
  );
  
  return <ChildComponent config={config} />;
};

2. Use Callbacks Appropriately

// ❌ Bad: New function on every render
const Component: React.FC = () => {
  const [count, setCount] = useState(0);
  
  return (
    <Button 
      onPress={() => setCount(count + 1)} // New function every render
    />
  );
};
 
// ✅ Good: Stable callback
const Component: React.FC = () => {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount(c => c + 1), []);
  
  return <Button onPress={increment} />;
};

Testing Component State

Testing Custom Hooks

Use @testing-library/react-hooks to test custom hooks.

// __tests__/hooks/useToggle.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useToggle } from '@/hooks/useToggle';
 
describe('useToggle', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useToggle());
    expect(result.current[0]).toBe(false);
  });
  
  it('should initialize with provided value', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current[0]).toBe(true);
  });
  
  it('should toggle the value', () => {
    const { result } = renderHook(() => useToggle(false));
    
    act(() => {
      const toggle = result.current[1];
      toggle();
    });
    
    expect(result.current[0]).toBe(true);
    
    act(() => {
      const toggle = result.current[1];
      toggle();
    });
    
    expect(result.current[0]).toBe(false);
  });
});

Troubleshooting