jsx-eslint / eslint-plugin-react

React-specific linting rules for ESLint
MIT License
8.99k stars 2.77k forks source link

Rule proposal: warn against using findDOMNode() #678

Closed gaearon closed 8 years ago

gaearon commented 8 years ago

There are almost no situations where you’d want to use findDOMNode() over callback refs. We want to deprecate it eventually (not right now) because it blocks certain improvements in React in the future.

For now, we think establishing a lint rule against it would be a good start. Here’s a few examples of refactoring findDOMNode() to better patterns.

findDOMNode(this)

Before:

class MyComponent extends Component {
  componentDidMount() {
    findDOMNode(this).scrollIntoView();
  }

  render() {
    return <div />
  }
}

After:

class MyComponent extends Component {
  componentDidMount() {
    this.node.scrollIntoView();
  }

  render() {
    return <div ref={node => this.node = node} />
  }
}

findDOMNode(stringDOMRef)

Before:

class MyComponent extends Component {
  componentDidMount() {
    findDOMNode(this.refs.something).scrollIntoView();
  }

  render() {
    return (
      <div>
        <div ref='something' />
      </div>
    )
  }
}

After:

class MyComponent extends Component {
  componentDidMount() {
    this.something.scrollIntoView();
  }

  render() {
    return (
      <div>
        <div ref={node => this.something = node} />
      </div>
    )
  }
}

findDOMNode(childComponentStringRef)

Before:

class Field extends Component {
  render() {
    return <input type='text' />
  }
}

class MyComponent extends Component {
  componentDidMount() {
    findDOMNode(this.refs.myInput).focus();
  }

  render() {
    return (
      <div>
        Hello, <Field ref='myInput' />
      </div>
    )
  }
}

After:

class Field extends Component {
  render() {
    return (
      <input type='text' ref={this.props.inputRef} />
    )
  }
}

class MyComponent extends Component {
  componentDidMount() {
    this.inputNode.focus();
  }

  render() {
    return (
      <div>
        Hello, <Field inputRef={node => this.inputNode = node} />
      </div>
    )
  }
}

Other cases?

There might be situations where it’s hard to get rid of findDOMNode(). This might indicate a problem in the abstraction you chose, but we’d like to hear about them and try to suggest alternative patterns.

timdorr commented 8 years ago

Some ideas to ease the transition:

Automatically reference the top node rendered on the component

class MyComponent extends Component {
  componentDidMount() {
    this._topDOMNode.scrollIntoView();
  }

  render() {
    return <div />
  }
}

Somehow do shorthand aliasing for the ref prop:

class MyComponent extends Component {
  componentDidMount() {
    this.node.scrollIntoView();
  }

  render() {
    return <div ref={this.node} />
  }
}

Provide a reference to the DOM node separately from the element reference:

class MyComponent extends Component {
  componentDidMount() {
    this.nodeRefs.myNode.scrollIntoView();
  }

  render() {
    return <div ref="myNode" />
  }
}
gaearon commented 8 years ago

In my opinion it seems like one of those cases where the only reason people want shortcuts is because they’ve been exposed to shorter magic syntax. Shorthands might seem “nice” but they actually make less sense coming from a beginner’s perspective. It’s easier to learn how the system works once than remember that topDOMNode is automatic but for everything else you need to use refs, or that this.node is somehow going to turn into a magic ref but there is just one such ref. As for the string suggestion, we won’t go with it because string refs are problematic by themselves, and so we want to get away from them as well.

vladshcherbin commented 8 years ago

@gaearon hey, can you leave a short note or is there a link to read why ref callbacks are preferred to ref strings? Thanks

notaurologist commented 8 years ago

@gaearon Great idea! However, is this in addition to a warning within React itself? If the React team definitely wants to deprecate this, seems like it should definitely warn there, as well. Not everyone may use ESLint, and IMO, it's not ESLint's responsibility to notify users about feature deprecation.

timdorr commented 8 years ago

@notaurologist There isn't a feature deprecation, just a potentially bad pattern, which is definitely ESLint's domain.

notaurologist commented 8 years ago

@timdorr I agree, but as @gaearon said: "We want to deprecate it eventually (not right now) because it blocks certain improvements in React in the future." I think this definitely warrants an additional warning in React.

PEM-- commented 8 years ago

Suppose that I want to create a behavior component that acts on the DOM of its provided child (like a fake mutation observer, for instance):

class Measure extends Component {
  componentDidMount() {
    const childNode = findDOMNode(this).children[0];
    // Here I call whatever I want when the child is loaded
    // @NOTE: There's no refs implied.
  }
  render() {
    const { children } = this.props;
    // Here, I'm agnostic to whatever the child might be, a pure function or a class
    return children;
  }
}

Now, as I'm agnostic to the type children passed like this:

<Measure>
  <AnyChildWithoutRefsLikePureComponent/>
</Measure>

I could clone the AnyChildWithoutRefsLikePureComponent, check it its a Component or a pure function, and if it's a pure function, turns it into a Component just to get a dynamic ref on it. But that would defeat the whole purpose of being agnostic to Component vs pure function.

soyarsauce commented 8 years ago

@gaearon small side note, in the after block - I believe the findDOMNode(childComponentStringRef) example is supposed to read this.inputNode.focus(); rather than this.inputNode.scrollIntoView();

fhelwanger commented 8 years ago

There's an example here of what @PEM-- described.

Basically, it's an element that pins its children scroll position to bottom as it grows.


const { render, findDOMNode } = ReactDOM

class PinToBottom extends React.Component {

  /// ...

  componentWillUpdate() {
    const node = findDOMNode(this)
    // ...
  }

  componentDidUpdate() {
    if (this.pinToBottom) {
      const node = findDOMNode(this)
      node.scrollTop = node.scrollHeight      
    }
  }

  render() {
    return React.Children.only(this.props.children)
  }
}

And then it can be used by any content container, like that:


<PinToBottom>
  <ul>
    {lines.map((line, index) => (
      <li key={index}>{line}</li>
    ))}
  </ul>
</PinToBottom>

I don't know how it could be made simpler by using callback refs or something else.

alaindresse commented 8 years ago

How do you suggest one deals with higher order functions over non-dom components, such as

var Wrapper = ComposedElement => class extends React.Component {
    componentDidMount() {
        // if ComposedElement is not a DOM component
        // this.domNode <> ReactDOM.findDOMNode(this)
    }

    render() {
        return <ComposedElement ref={r=>{this.domNode = r}}/>
    }
};
ljharb commented 8 years ago

@alaindresse why would that work any differently using this.domNode? the wrapped component would still work the same with the ref as it would with findDOMNode.

okonet commented 8 years ago

I'm also wondering how is HOCs like this should be re-written then?

https://github.com/okonet/react-container-dimensions/blob/master/src/index.js

koenpunt commented 8 years ago

Your third example's "After" doesn't match with "Before".

this.inputNode.scrollIntoView();

Should be

this.inputNode.focus();
PEM-- commented 8 years ago

Actually, it seems to me that all examples drill down to the same behavior. If we could setup a callback ref on a children passed as prop, the findDOMNode could then be removed.

A function like React.Children.addCallbackRef could do it.

Sounds legitimate. A parent can call its children 😉

@gaearon, what do you think of this?

gaearon commented 8 years ago

@timdorr I agree, but as @gaearon said: "We want to deprecate it eventually (not right now) because it blocks certain improvements in React in the future." I think this definitely warrants an additional warning in React.

Deprecating is adding a warning. So we plan to add a warning but in the future. I thought that maybe ESLint rule might be a better first idea before we deprecate it.

alaindresse commented 8 years ago

@ljharb in the HOC, you don't know if ComposedElement is a DOM or a react class. The reference is then either a DOM node, or an instance of a react class. In the latter case, you need findDOMNode to get the actual dom node...

One idea would be to have two arguments in the ref callback

ref={(r,n)=>{this.component = r; this.node = n}

if the component is a dom node, r===n. If the component is a react class, n is the topDOMNode @timdorr referred to earlier.

gaearon commented 8 years ago

Suppose that I want to create a behavior component that acts on the DOM of its provided child

Yes, HOCs like this would have to wrap their content in an extra <div>. This sounds bad until you see that the whole concept is flawed.

People often request that React adds support for returning multiple nodes from render (“fragments”). Imagine we implement this. Now any component can either return zero, one, or many nodes.

Somebody changes one of the “measured” components to return two nodes in some state. What should findDOMNode return? First node? An array?

Whether a component returns many nodes is its implementation detail. It should be possible to change it without breaking any code in other components, but this would not work with our HOC. So the pattern itself is problematic.

There are two solutions:

  1. Make HOC add a wrapping <div>. This is the easiest way to preserve encapsulation.
  2. If absolutely necessary, instead let HOC inject a callback prop like refToMeasure so wrapped component can use <div ref={this.props.refToMessure}>. This is identical to how my last example works. Components explicitly exposed nodes they want to.

Reading nodes of child components is like wanting to access their state. This is not a pattern we should support or allow (even if it is technically possible). If it was unsupported from day one, I don’t think it would be much of a controversy. However it is less obvious that the pattern is problematic because it’s been possible for a while.

gaearon commented 8 years ago

If we could setup a callback ref on a children passed as prop, the findDOMNode could then be removed.

You can as long as children are DOM elements. You can check for that with this.props.children && typeof this.props.children.type === 'string'. In this case, to attach a callback ref, you can use cloneElement with a ref callback that calls the original ref function if it exists, and then does whatever you needed.

For reasons above you cannot do this on custom components.

PEM-- commented 8 years ago

Indeed, I agree. It's like having a form that parses its input fields instead of giving the fields the capabilities to inform the form itself. It's parsing against eventing. And that's against the purpose of React 👍

DOM based operation should ensure that a parsable DOM is present.

Andarist commented 8 years ago

@PEM @fhelwanger

its possible to clone children with ref callback added to it, so it can be exposed to a wrapper component

https://codepen.io/Andarist/pen/qNaNGY

PEM-- commented 8 years ago

@Andarist: Thanks but it only works if your children are immediate DOM elements 😉

fhelwanger commented 8 years ago

@Andarist @PEM-- Yes! The nice (or bad :smile:) thing about findDOMNode is that it can be another React.Component, something like:

<PinToBottom>
  <List>
    {lines.map((line, index) => (
      <ListItem key={index}>{line}</ListItem>
    ))}
  </List>
</PinToBottom>

And it'll find its DOM node anyway.

One can argue that by using findDOMNodeyou're breaking the component's encapsulation, but besides that, I think that for this particular use case findDOMNode is more straightforward than cloneElement + callback refs. But maybe it's just me :wink:

fhelwanger commented 8 years ago

Just read @gaearon comments, and I agree 1000% with:

Whether a component returns many nodes is its implementation detail. It should be possible to change it without breaking any code in other components, but this would not work with our HOC. So the pattern itself is problematic.

Now that I understand the problem better and how this make returning multiple nodes from render very difficult, I rewrote the example to wrap every children of PinToBottom inside a div.

It's much cleaner and doesn't break encapsulation!

https://codepen.io/anon/pen/qNVrwW?editors=0010

yannickcr commented 8 years ago

To come back to the ESLint rule

Deprecating is adding a warning. So we plan to add a warning but in the future. I thought that maybe ESLint rule might be a better first idea before we deprecate it.

I'm agree, also adding a rule like this is pretty easy (we've already done something similar for isMounted()).

pandaiolo commented 8 years ago

As an additional example for those who need access to a component DOM node from its parent but also from the component itself, here is the modified code from @gaearon example:

class Field extends Component {
  render() {
    return (
      <input type='text' ref={node => this.props.inputRef(node) && (this.node = node)} />
    )
  }
}

It seems somewhat trivial but it took me a bit of time to figure it out so it may help anybody with the same use case.

perrin4869 commented 8 years ago

Another problem I can come up with, is when it comes to animating properties like scrollTop. Animating scrollTop with any animation library requires the actual DOM node. For example, the only way to add animation to @fhelwanger's pin to bottom codepen example would be to manually hardcode the animation logic, instead of being able to reuse Velocity or jQuery, etc

gaearon commented 8 years ago

@perrin4869 I described this use case in the very first post in this thread. You can get the DOM node without findDOMNode(), just use a ref.

fhelwanger commented 8 years ago

@perrin4869 Check this: https://github.com/yannickcr/eslint-plugin-react/issues/678#issuecomment-232332949

You can use the ref available inside PinToBottom to get the DOM node you need. Then you can pass it to jQuery, Velocity, etc.

perrin4869 commented 8 years ago

Hm... I'm confused then. In the snippet below, is there any difference between this.node and this.domNode, and could it affect how animation libraries work?

class Foo extends Component {
  componentDidMount() {
    this.domNode = findDOMNode(this.node);
  }

  render() {
    return <div ref={c => { this.node = c; }} />;
  }
}
fhelwanger commented 8 years ago

@perrin4869 You don't need this.domNode = findDOMNode for it to work. The ref prop on your div already gives you the DOM element. Thus, this.node is already your DOM element.

You can do something like the following:

class Foo extends Component {
  componentDidMount() {
    $(this.node).slideDown();
  }

  render() {
    return <div ref={c => this.node = c } />;
  }
}
pandaiolo commented 8 years ago

@perrin4869 the use case where there is a difference, is illustrated by @gaearon in the last example of his first post, is when you reference a react component - and not already a DOM element like the div in your example - and need to access the actual DOM node of this component (eg to use the focus() method of an input tag).

In that case, the children component needs to expose a prop to forward the DOM node to its parent, as shown in the example, where previously the parent would just use a ref on the children component and call findDOMNode()

koenpunt commented 8 years ago

@pandaiolo but you probably shouldn't be accessing the DOM node from another component.

pandaiolo commented 8 years ago

Isn't the TextField a good example? I also have a video component enriching the video DOM element, which I need to access props and methods (play(), pause(), paused, currentTime, etc). Is there a reason I would not let the parent access the video DOM node?

koenpunt commented 8 years ago

I think the best solution would be to let the video component have a isPlaying prop, which changes trigger the required methods on the video component.

Something like this:

class Video extends React.Component {

  getDefaultProps() {
    return { isPlaying: false }
  }

  componentDidUpdate(prevProps) {
    if(this.props.isPlaying !== prevProps.isPlaying) {
      this.props.isPlaying ? this.video.play() : this.video.pause()
    }
  }

  render() {
    return <video ref={(el) => this.video = el} src="..."></video>
  }
}
pandaiolo commented 8 years ago

The component is simple and just exposes the video events as a prop. I could explicitly rewire all the native methods and props as well, but i'm not sure the added complexity is worth the linter code in that case. In the end that would just mean putting back the video element in the parent component.

Thanks for pointing this anyway, I fully adhere to the "React way" so I understand that based on the simple example, it makes sense to ditch the external ref!

koenpunt commented 8 years ago

Are you using React without Redux or flux or anything? Because then I can understand. But when you do use one of those, you really want to control the video using the state of the application.

pandaiolo commented 8 years ago

I do in the parent component which concern is about video control. Putting the video element back there would add rather than remove complexity / readability - but yeah it's my call :)

gaearon commented 8 years ago

Isn't the TextField a good example? I also have a video component enriching the video DOM element, which I need to access props and methods (play(), pause(), paused, currentTime, etc). Is there a reason I would not let the parent access the video DOM node?

In this case you can put them on the TextField instance. Then the parent will have a ref to that instance, and call methods on it. And TextField will use its own ref to DOM element.

play() {
  this.node.play()
}

etc

It’s more typing but it’s also more explicit and doesn’t leak implementation details.

lencioni commented 8 years ago

This is the Law of Demeter

jquense commented 8 years ago

deprecating findDOMNode is a an alarmingly upseting idea... Unless you are adding back getDOMNode(), it's going to make writing a large amount of react code just so so terrible. "Add and extra DOM node to use callback refs" is really really untenable for a ton of use cases; along with, effectively making interop with css frameworks not written with react almost impossible.

jquense commented 8 years ago

not to mention it effectively just ignores the fact that multiple layers of HOCs are the norm now thanks to mixin "deprecation" :P

This sounds like an idea from folks who nicely own every single component they use. for those using libraries... oof

gaearon commented 8 years ago

If you have specific examples of problematic patterns please post them in the thread. This is exactly what I did in the post, and it would really help us weigh on this decision (and maybe think of better alternatives). It would help us a lot if you could do the same thing. You might think your concerns are obvious and we are intentionally ignoring them. In reality it's not at all obvious what you are concerned about without code samples. Thanks!

I also believed I explained above in this thread why findDOMNode API is fundamentally incompatible with some features long requested by the community (such as fragments). We are not deprecating it just because we enjoy deprecating things. :wink:

taion commented 8 years ago

The case where this comes up most acutely is having a positioner component for e.g. tooltips and overlays.

Consider:

<Button ref={c => { this.button = c; }} />
<Position target={this.button}>
  <Tooltip>Some tooltip</Tooltip>
</Position>

For <Position> to work as a generic component that can e.g. calculate the size of its child, then position it appropriately relative to its target, it needs to be able to get the child's DOM node.

To be compatible with e.g. Bootstrap styling, it's not generally possible for a component like <Position> to render an extra <div>.

<Position> just adding a ref to its child is insufficient, since its child may well be some composite component (e.g. <Tooltip> above) rather than a DOM component.

taion commented 8 years ago

The category in general is utility components that need to interact with the DOM nodes belonging to their children, for functionality such as positioning, animation, &c.

If you control all of your own styling, then perhaps the extra <div> is acceptable, though not ideal. If you don't control your CSS, then adding an extra <div> can be a non-option.

jquense commented 8 years ago

Sorry if I sound annoyed, these sort of drastic changes can feel like they are coming out of the blue. A proper RFC process for this stuff would really be helpful here.

to add to @taions point, so also for focus management, which is required for accessible components, right now react just offers nothing useful for that problem which requires dropping down to DOM nodes, and not wrapping ones in the HOC case. imperative methods sort of work but are often and frustratingly obscured by HOCs, and just not possible for function components.

The extra DOM nodes are often presented as a "just do that" sort of simple solution but I feel like that ignores that layout and styling in the DOM is extremely intolerant to adding in bonus nodes. not to mention the lack of semantic value. To be honest I think most often the reason want folks want fragments (maybe one of the most requested API changes?) in the first place is exactly to avoid these bonus nodes.

I appreciate the desire to really abstract away the DOM as a render target, but these escape hatches are important not the least of which because the DOM is not particularly a good target for this sort of thing and wildly quirky (I encourage folks to look at the react event code to see just what is needed for consistency on that front).

Saying you can only drop to the host objects via function refs feels like a line drawn against an arbitrary and unhelpful line. Escape hatches should be used prudently and judiciously but they should be easily and constitently accessible, otherwise they aren't useful in any case. This is the same problem with stateless function components not being ref-able. there is no real reason to think someone would not need the escape hatch for those components over classy ones.

it seems like if findDOMNode is blocking other features then perhaps change they way it works, instead of removing it? Personally I'd be fine if it sometimes returned an array of nodes vs a single one, jQuery has done this with a ton of success forever

lencioni commented 8 years ago

If you have specific examples of problematic patterns please post them in the thread.

@gaearon I ran into a problematic pattern today where I think I need findDOMNode. This is a bit of an unusual situation and if you can see an alternate solution, I would love to know.

I am currently working on setting up Happo to do visual regression testing in CI. The basic idea is we have some code that:

  1. renders a React component to the DOM
  2. finds the coordinates the rendered component occupies in the DOM
  3. takes a screenshot of the page
  4. crops the screenshot to match the coordinates

In order to get any component's coordinates, we need a reference to the DOM node. Because you can't use ref on SFCs, we have a basic wrapper component that we wrap when rendering these examples:

class Wrapper extends React.Component {
  render() {
    return React.Children.only(this.props.children);
  }
}

Then, inside our example, we have some code like:

const container = document.createElement('div');
document.body.appendChild(container);
let elemRef;
const elem = (
  <Wrapper ref={(ref) => { elemRef = ref; }}>
    {example}
  </Wrapper>
);
ReactDOM.render(elem, container);
setTimeout(() => done(ReactDOM.findDOMNode(elemRef)), 0);

From this DOM node reference, we find the coordinates via getBoundingClientRect().

Perhaps I've overlooked something, but is there currently a way to achieve this without ReactDOM.findDOMNode()? We thought that perhaps we could wrap it in an inline-block <div> with a ref (which then gives us a DOM node), but that changes the styling characteristics of some components (e.g. display: block elements don't stretch out).

taion commented 8 years ago

Is there an issue tracking this on facebook/react?

It's a bit weird to discuss it here. The lint rule is fine, and I think we all agree that many or even most uses of findDOMNode are better handled with a ref on a DOM component.

The eslint-plugin-react repo is not the right place to discuss whether findDOMNode should exist as a React DOM feature.

gaearon commented 8 years ago

Please feel free to file a discussion on React repo, sure!

gaearon commented 8 years ago

This is the same problem with stateless function components not being ref-able.

What would you expect to get as a reference to a functional component? Since it doesn’t have an instance, it’s not clear what you are suggesting.

gaearon commented 8 years ago

@lencioni

Wouldn’t this work for your use case?

const container = document.createElement('div');
document.body.appendChild(container);
ReactDOM.render(example, container);
setTimeout(() => done(container.firstChild), 0);