The Problem With forwardRef
React's forwardRef
has some properties that make it annoying when working with generic components.
Here we have a Table
component which we then wrap with forwardRef
:
export const Table = <T>(
props: Props<T>,
ref: ForwardedRef<HTMLTableElement>
) => {
return <table ref={ref}
Transcript
00:00 Let's talk about ForwardRef and React. ForwardRef, as well as a couple of other functions, has a property where it can be very, very annoying to work with when you're working with generic components. So we have this table down here, and then we have a ForwardRef table, which is we're just wrapping this ForwardRef
00:19 or wrapping the table with ForwardRef. And now what's going on here is that the table basically takes in this ref and then passes it to the table element below it. And ForwardRef allows us to basically construct a React component here that takes in two arguments, first the props and then the ref.
00:37 So you would expect then that this table ref, first of all, the table ref is working great. Like this ref now that's required by ForwardRef table, it's exactly the ref that we specified above. So HTML table element is being inferred properly. And if I remove this tsExpectError, then we can see that passing in the wrong ref,
00:56 HTML div element into HTML table element is saying it's missing a bunch of properties like bgColor, borderCaption, cellPadding, etc. So the ref is working great. It's just the inference inside the generic component no longer works. So you can see here that basically we've got our data here,
01:16 which we're passing in an array of T, and then renderRow, we basically take in that row and then render a React node out. Except that we're passing in an array of strings, so you would expect renderRow here, this row to be type string, but it's not, it's type unknown. And if I just change this to a table,
01:35 well, we get rid of our ref here, but what we do get is this row is now typed as string. So, right. So the reason this is happening is rather complex, but I will explain it in a bit. But first, I'm going to show you the solution. You can actually globally override the value of ForwardRef here
01:52 by using DeclareModuleReact. And if I uncomment this, then it actually starts working. So this is possible in TypeScript, but it's like, as we can see now, ForwardRef, it just works. And if I change this to a number instead of a string, then this is going to start erroring because this row is supposed to be a number, not a string.
02:12 How cool is that? So this actually, you can just take and drop into your projects if you want to. And I think actually, in a lot of cases, it's going to work great. The only thing you don't get from this is now ForwardRefTable, for instance. ForwardRefTable, it doesn't have anything like default props on it, and it doesn't have things like, what was the other one, like context types
02:33 or things like that. And if I uncomment this or comment it back up rather, then it does. So default props is now on it, and display name as well as the other actually useful one. So you win some and you lose some basically. And the reason this is happening is because this version of ForwardRef, this was put together by Stephen Baumgartner,
02:52 who's another really great CS wizard. It basically sort of says, okay, we don't care about default props and we don't care about display name enough to basically ruin our inference in favor of them. And I'll show you why that happens in a second. This is really cool because it even works across module boundaries. So if I comment it out again
03:12 and actually just go to this second file I've got here, then if I comment it out, then in this file, it will start erroring. But if I uncomment it, wow, it now starts working. Really nice. So you can just drop this into your project and it will just start working. So let me dive deep into why it actually isn't working
03:34 for like to begin with. So we can imagine here, let's imagine that we have a remove inference function. And this function basically just accepts currently what we're describing as an FC, right? So a functional component. And this FC here, I've got,
03:53 basically it's like an object, but the object can be called. This object has a call signature. And when you call it, it takes in props and it returns React.ReactNode. But it's also got some other thing on it. And the crazy thing here is we're basically just saying table without inference, remove inference table.
04:11 This table is exactly the same as the one we had above or in the previous file. And the table without inference no longer works. So this row no longer works. Whereas this table, it does work. How crazy is that? The reason this is working is that
04:29 because of this type here, this FC type, if we comment this out, we comment out some other thing, it starts working. But because TypeScript is seeing, I don't know quite why this works this way, but TypeScript sees that, okay,
04:46 this type is slightly more complicated than a function. So I'm not going to apply any kind of higher order function logic to it. Because here, this component FC props, it could just say, okay, I understand that what you're passing in is a generic function. So I will preserve its generosity. And you can see that it does do that.
05:04 So here you can see that there's a T at the start of here indicating that this function, this table without inference function takes a type argument. But when we comment this out, or uncomment it rather, then it no longer takes in a type argument. And we've just got FC table props unknown.
05:22 So the reason then that it doesn't work in React like this is because the react.fc type has display name on it. So if we look at, let's say we've got props in here. So forward ref, let's take a look at the actual definition. Here, we've got T forward ref render function.
05:41 Yeah, here we go. We've got props P and here we go. It's the definition of this forward ref render function that has display name, default props and prop types. And I imagine actually, I'm just going to try this and just see if it works. There might be more complicated stuff going on. But if I comment this out
05:57 and I uncomment my kind of like better version, does that just start working? No, it doesn't. Oh no, it doesn't because there's other, I imagine there's other things going on. That was like a shot in the dark that didn't end up paying off. But essentially this is why it happens, right? It's because of these extra things on here
06:16 that mean that TypeScript doesn't infer it too deeply or rather doesn't like apply some higher order function logic to it. So a forward ref implementation is possible. And I actually do recommend that you take this one here and stick it in your projects
06:32 because like React may fix this at some point. And currently forward ref is really painful to work with, with kind of like generic components.