Why don't we pass child components as props?
Filip Egeric
Posted on August 8, 2024
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
}
}
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 (...)")
}
}
Normally the database is injected through the constructor and it would be an interface:
interface Database {
executeQuery(query: string): Row[]
}
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[] {
// ...
}
}
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[] {
// ...
}
}
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) { ... }
}
So when the UserService
is instantiated it would look like this:
new UserService(new LoggingDatabase(new MySqlDatabase(mysqlClient)))
or:
new UserService(new LoggingDatabase(new PgDatabase(pgClient)))
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>
)
}
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:
- Number input
const AgeInput: FC<{ onChange: (age: number) => void }> = ({ onChange }) => {
return <input type="number" onChange={e => onChange(Number(e.target.value))} />
}
- 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))} />
}
or it could be anything else, as long as it accepts props like this:
interface AgeInputProps {
onChange: (age: number) => void
}
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>
)
}
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} />
)
}
So? Why don't we?
Posted on August 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.