
React Architecture: Mastering State and Props in Class vs. Functional Components
Compare React's legacy class components with modern functional hooks by building a state-managed counter app in both paradigms.
The Shift in Mental Models
If you started using React after 2019, you might view Class components as artifacts of a bygone era. If you started before then, you remember the boilerplate heavy lifting required just to increment a number. As an automation engineer, I look at code through the lens of efficiency and maintainability. The transition from Class components to Functional components wasn't just a syntax update; it was a fundamental shift in the mental model of how we handle UI state.
Understanding both paradigms is not optional for a senior developer. You will encounter legacy codebases, and you will need to refactor them. Moreover, understanding how React managed state in classes gives you a profound appreciation for the closure-based architecture of Hooks.
In this build, we are going to strip away the complex business logic and focus purely on the mechanics. We will build a Smart Counter Application. We will build it twice: once using the Object-Oriented approach (Classes) and once using the Functional approach (Hooks). We will dissect how Props (data input) and State (internal memory) behave in both.
Part 1: The Props Paradigm
Props (properties) are the mechanism for passing data down the component tree. Regardless of the component type, props are read-only (immutable). The component receives them, but cannot change them. However, how we access them differs.
Class Components: this.props
In a class component, props are accessed via the instance of the class using the this keyword. This relies heavily on the context of the object instance.
class Welcome extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; }}The subtle danger here lies in the lifecycle. If this.props changes over time, the render method captures the current value. However, inside asynchronous callbacks, this.props might have changed by the time the callback executes, leading to race conditions.
Functional Components: Argument Destructuring
Functional components represent a purer form of UI-as-a-function. Props are simply the first argument passed to the function.
const Welcome = ({ name }) => { return <h1>Hello, {name}</h1>;};Here, the closure captures the render-time value. This makes functional components inherently more predictable in asynchronous flows. There is no this context to worry about.
Part 2: The State Architecture
This is where the divergence is most apparent. State allows a component to change its output over time in response to user actions.
The Class Approach: One Object to Rule Them All
In classes, state is a single object. You initialize it in the constructor, and you update it using this.setState(). Crucially, setState performs a shallow merge.
// Initializingthis.state = { count: 0, status: 'active'};// Updatingthis.setState({ count: this.state.count + 1 });// 'status' remains 'active' automaticallyThe Functional Approach: Independent State Slices
With the useState hook, we don't typically use a single object (though we can). We slice state into independent variables.
const [count, setCount] = useState(0);const [status, setStatus] = useState('active');// UpdatingsetCount(prev => prev + 1);Builder Note: The useState updater function does not automatically merge objects. If you use an object in useState, you must manually spread the previous state: setState(prev => ({ ...prev, count: prev.count + 1 })).
The Build: Smart Counter (Class Version)
Let's build a counter that accepts a startValue via props and manages the count via state.
import React, { Component } from 'react';class CounterClass extends Component { constructor(props) { super(props); // 1. Initialization this.state = { count: props.startValue || 0, lastAction: 'none' }; // 2. Binding methods (The "this" headache) this.increment = this.increment.bind(this); this.decrement = this.decrement.bind(this); } increment() { // 3. Merging state this.setState((prevState) => ({ count: prevState.count + 1, lastAction: 'increment' })); } decrement() { this.setState((prevState) => ({ count: prevState.count - 1, lastAction: 'decrement' })); } render() { return ( <div className="p-6 border rounded-lg shadow-sm"> <h3>Class Counter</h3> <p>Start Value (Prop): {this.props.startValue}</p> <div className="text-4xl font-bold my-4"> {this.state.count} </div> <p className="text-gray-500 mb-4">Last Action: {this.state.lastAction}</p> <div className="flex gap-2"> <button onClick={this.decrement} className="px-4 py-2 bg-red-500 text-white rounded">-</button> <button onClick={this.increment} className="px-4 py-2 bg-blue-500 text-white rounded">+</button> </div> </div> ); }}export default CounterClass;Analysis of the Class Build
- Boilerplate: Look at the constructor. We have to call
super(props)just to accessthis.props. - Binding: If we didn't bind
this.incrementin the constructor (or use class field arrow functions),thiswould be undefined when the button is clicked. This was the source of confusion for thousands of React developers for years. - Verbosity: The logic is spread across the constructor, the methods, and the render function.
The Build: Smart Counter (Functional Version)
Now, let's refactor this into a modern functional component using Hooks.
import React, { useState } from 'react';const CounterFunctional = ({ startValue = 0 }) => { // 1. Isolated State Slices const [count, setCount] = useState(startValue); const [lastAction, setLastAction] = useState('none'); const increment = () => { setCount(prev => prev + 1); setLastAction('increment'); }; const decrement = () => { setCount(prev => prev - 1); setLastAction('decrement'); }; return ( <div className="p-6 border rounded-lg shadow-sm"> <h3>Functional Counter</h3> <p>Start Value (Prop): {startValue}</p> <div className="text-4xl font-bold my-4"> {count} </div> <p className="text-gray-500 mb-4">Last Action: {lastAction}</p> <div className="flex gap-2"> <button onClick={decrement} className="px-4 py-2 bg-red-500 text-white rounded">-</button> <button onClick={increment} className="px-4 py-2 bg-blue-500 text-white rounded">+</button> </div> </div> );};export default CounterFunctional;Analysis of the Functional Build
- No Constructor: State initialization happens inline. Code is read top-to-bottom.
- No Binding: Because
incrementis a function defined inside the component scope, it inherently has access to the component's scope. Nothisbinding required. - Destructuring Props: We pull
startValuedirectly from the function arguments, setting a default value cleanly. - Separation of Concerns: We separated
countandlastAction. This allows the React runtime to optimize updates better than a single large object, although we could group them if they were tightly coupled.
Deep Dive: The "Gotchas"
While the functional version looks cleaner, there are engineering nuances you must respect.
1. Stale Closures
In Class components, this.state.count always points to the latest instance. In Functional components, functions capture values at the time they were created. If you use setTimeout inside the functional counter, it might print an old value of count unless you use a Ref or proper dependency arrays in useEffect.
2. The Merge vs. Replace
As mentioned earlier, this.setState merges. useState replaces. If you are migrating a complex form from a Class to a Function, this is where bugs happen. You must manually spread your object: setState({ ...state, field: value }).
3. Initialization Performance
In the Class constructor, state initialization runs once. In the Functional component, the line useState(expensiveComputation()) runs on every render (though React ignores the result on subsequent renders). If initializing your state is computationally expensive, use lazy initialization:
// Only runs onceconst [value, setValue] = useState(() => expensiveComputation());Avnishâs Verdict
I build automation systems where complexity creates friction. Class components introduce "incidental complexity"âcomplexity related to the mechanism of the language (classes, `this` binding) rather than the problem I'm solving.
Functional components with Hooks strip that away. They allow us to compose logic. If I wanted to share the counter logic between two components, in a Class world, I'd need Higher Order Components (HOCs) or Render Propsâboth messy patterns. In a Functional world, I simply extract the logic into a custom hook: useCounter.
However, respect the Class component. It forces you to think about lifecycle methods (Mounting, Updating, Unmounting) in a very explicit way. If you are struggling to understand useEffect, go write a Class component using componentDidUpdate. The clarity you gain there will make you a better Hooks developer.
Start building with Functions, but know your history.
Comments
Loading comments...