Why don't we pass child components as props?

filipegeric

Filip Egeric

Posted on August 8, 2024

Why don't we pass child components as props?

Even though the title of this article suggests that it will be about React and front-end, we'll actually start from the other side.

How it's usually done in the back-end

Let's take a look at the following piece of code:

class UserService {

  register(request: RegisterRequest) {
    // TODO
  }

}
Enter fullscreen mode Exit fullscreen mode

In the register method we would most likely validate the request create a new user and then save it to the database. Let's focus on the saving to the database part. Here is what that would look like:

class UserService {

  constructor(private database: Database) {}

  register(request: RegisterRequest) {
    // validate and process request
    this.database.executeQuery("INSERT INTO users VALUES (...)")
  }

}
Enter fullscreen mode Exit fullscreen mode

Normally the database is injected through the constructor and it would be an interface:

interface Database {
  executeQuery(query: string): Row[]
}
Enter fullscreen mode Exit fullscreen mode

And there would be (at least one) implementation of that interface:

import { MySqlClient, MySqlResult } from 'some-mysql-package'

class MySqlDatabase implements Database {

  constructor(private mysql: MySqlClient) {}

  executeQuery(query: string): Row[] {
    const result = this.mysql.query(query)
    return this.rowsFrom(result)
  }

  private rowsFrom(result: MySqlResult): Row[] {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the reason for abstracting the database in this way is easier testing and weaker coupling with a specific implementation. So if we ever wanted to replace mysql with postgres, we wouldn't have to touch (change any code in) UserService. Instead we would just add another implementation of the Database interface and use it.

import { PgClient, PgResult } from 'pg-database-package'

class PgDatabase implements Database {

  constructor(private pg: PgClient) {}

  executeQuery(query: string): Row[] {
    const result = this.pg.execute(query)
    return this.rowsFrom(result)
  }

  private rowsFrom(result: PgResult): Row[] {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Now this abstraction may seem a bit unnecessary, but if we tried using some-mysql-package directly in the UserService, instead of the Database interface, it would hardly pass the code review.

There are other benefits to this approach.
What if we wanted to log each query that's being run. We could do something like this:

class LoggingDatabase implements Database {

  constructor(private actualDatabase: Database) {}

  executeQuery(query: string): Row[] {
    this.log(`Executing query: ${query}`)
    return this.actualDatabase.executeQuery(query)
  }

  private log(message: string) { ... }
}
Enter fullscreen mode Exit fullscreen mode

So when the UserService is instantiated it would look like this:

new UserService(new LoggingDatabase(new MySqlDatabase(mysqlClient)))
Enter fullscreen mode Exit fullscreen mode

or:

new UserService(new LoggingDatabase(new PgDatabase(pgClient)))
Enter fullscreen mode Exit fullscreen mode

Patterns like this are quite common in the back-end.

But what about front-end?

Going back to the front-end and React, let's assume there is now a register form:

import { TextInput, AgeInput } from 'components'
import { register } from 'some-api'

const RegisterForm: FC = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [age, setAge] = useState<number | null>(null)

  function register(e: SubmitEvent) {
    e.preventDefault()
    register({ email, password, age })
  }

  return (
    <form onSubmit={register}>
      <TextInput placeholder="email" onChange={setEmail} />
      <TextInput placeholder="password" onChange={setPassword} />
      <AgeInput onChange={setAge} />
      <button>Register</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

And let's focus on this line:

<AgeInput onChange={setAge} />

From the perspective of RegisterForm component, it is not important how the AgeInput is implemented. The only thing that's important is that it accepts a prop onChange of type (age: number) => void.

So let's look at the possible implementations of this AgeInput component:

  1. Number input
const AgeInput: FC<{ onChange: (age: number) => void }> = ({ onChange }) => {
  return <input type="number" onChange={e => onChange(Number(e.target.value))} />
}
Enter fullscreen mode Exit fullscreen mode
  1. Slide input
const AgeInput: FC<{ onChange: (age: number) => void }> = ({ onChange }) => {
  return <input type="range" min="0" max="120" step="1" onChange={e => onChange(Number(e.target.value))} />
}
Enter fullscreen mode Exit fullscreen mode

or it could be anything else, as long as it accepts props like this:

interface AgeInputProps { 
  onChange: (age: number) => void 
}
Enter fullscreen mode Exit fullscreen mode

So why aren't we injecting it instead of importing it:

import { TextInput, AgeInputProps } from 'components'
import { register } from 'some-api'

const RegisterForm: FC<{ AgeInput: FC<AgeInputProps> }> = ({ AgeInput }) => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [age, setAge] = useState<number | null>(null)

  function register(e: SubmitEvent) {
    e.preventDefault()
    register({ email, password, age })
  }

  return (
    <form onSubmit={register}>
      <TextInput placeholder="email" onChange={setEmail} />
      <TextInput placeholder="password" onChange={setPassword} />
      <AgeInput onChange={setAge} />
      <button>Register</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

This way we could choose which implementation to use without touching the RegisterForm and we could even implement some side effects (or maybe even interceptors) similarly to LoggingDatabase:

import { AgeInput, AgeInputProps } from 'components'

const LoggingAgeInput: FC<AgeInputProps> = (props) => {

  function onChange(age: number) {
    console.log(`Age changed to: ${age}`)
    props.onChange(age)
  }

  return (
    <AgeInput {...props} onChange={onChange} />
  )
}
Enter fullscreen mode Exit fullscreen mode

So? Why don't we?

💖 💪 🙅 🚩
filipegeric
Filip Egeric

Posted on August 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related