skip to Main Content

I’m working on a Notification feature in my app (pretty much like Facebook notifications).

When I click a button in the header navigation, the dropdown opens and shows the notification list. The notification has a Link (from react-router) in it.

What I need to do is to close the dropdown whenever a Link is clicked.

Here’s roughly the hierarchy I currently have:

Header > Navigation > Button > Dropdown > List > Notification > Link

Since the dropdown functionality is used more that once, I’ve abstracted its behavior away into a HOC that uses render prop:

export default function withDropDown(ClickableElement) {
  return class ClickableDropdown extends PureComponent {
    static propTypes = {
      children: PropTypes.func.isRequired,
      showOnInit: PropTypes.bool,
    };

    static defaultProps = {
      showOnInit: false,
    };

    state = {
      show: !!this.props.showOnInit,
    };

    domRef = createRef();

    componentDidMount() {
      document.addEventListener('mousedown', this.handleGlobalClick);
    }

    toggle = show => {
      this.setState({ show });
    };

    handleClick = () => this.toggle(true);

    handleGlobalClick = event => {
      if (this.domRef.current && !this.domRef.current.contains(event.target)) {
        this.toggle(false);
      }
    };

    render() {
      const { children, ...props } = this.props;
      return (
        <Fragment>
          <ClickableElement {...props} onClick={this.handleClick} />
          {this.state.show && children(this.domRef)}
        </Fragment>
      );
    }
  };
}

The HOC above encloses the Button component, so I have:

const ButtonWithDropdown = withDropdown(Button);

class NotificationsHeaderDropdown extends PureComponent {
  static propTypes = {
    data: PropTypes.arrayOf(notification),
    load: PropTypes.func,
  };

  static defaultProps = {
    data: [],
    load: () => {},
  };

  componentDidMount() {
    this.props.load();
  }

  renderDropdown = ref => (
    <Dropdown ref={ref}>
      {data.length > 0 && <List items={this.props.data} />}
      {data.length === 0 && <EmptyList />}
    </Dropdown>
  );

  render() {
    return (
      <ButtonWithDropdown count={this.props.data.length}>
        {this.renderDropdown}
      </ButtonWithDropdown>
    );
  }
}

List and Notification are both dumb functional components, so I’m not posting their code here. Dropdown is pretty much the same, with the difference it uses ref forwarding.

What I really need is to call that .toggle() method from ClickableDropdown created by the HOC to be called whenever I click on a Link on the list.

Is there any way of doing this without passing that .toggle() method down the Button > Dropdown > List > Notification > Link subtree?

I’m using redux, but I’m not sure this is the kind of thing I’d put on the store.

Or should I handle this imperatively using the DOM API, by changing the implementation of handleGlobalClick from ClickableDropdown?


Edit:

I’m trying with the imperative approach, so I’ve changed the handleGlobalClick method:

const DISMISS_KEY = 'dropdown';

function contains(current, element) {
  if (!current) {
    return false;
  }

  return current.contains(element);
}

function isDismisser(dismissKey, current, element) {
  if (!element || !contains(current, element)) {
    return false;
  }

  const shouldDismiss = element.dataset.dismiss === dismissKey;

  return shouldDismiss || isDismisser(dismissKey, current, element.parentNode);
}

// Then...
handleGlobalClick = event => {
  const containsEventTarget = contains(this.domRef.current, event.target);
  const shouldDismiss = isDismisser(
    DISMISS_KEY,
    this.domRef.current,
    event.target
  );

  if (!containsEventTarget || shouldDismiss) {
    this.toggle(false);
  }

  return true;
};

Then I changed the Link to include a data-dismiss property:

<Link
  to={url}
  data-dismiss="dropdown"
>
  ...
</Link>

Now the dropdown is closed, but I’m not redirected to the provided url anymore.

I tried to defer the execution of this.toggle(false) using requestAnimationFrame and setTimeout, but it didn’t work either.


Solution:

Based on the answer by @streletss bellow, I came up with the following solution:

In order to be as generic as possible, I created a shouldHideOnUpdate prop in the ClickableDropdown dropdown component, whose Hindley-Milner-ish signature is:

shouldHideOnUpdate :: Props curr, Props prev => (curr, prev) -> Boolean

Here’s the componentDidUpdate implementation:

componentDidUpdate(prevProps) {
  if (this.props.shouldHideOnUpdate(this.props, prevProps)) {
    this.toggle(false);
  }
}

This way, I didn’t need to use the withRouter HOC directly in my withDropdown HOC.

So, I lifted the responsibility of defining the condition for hiding the dropdown to the caller, which is my case is the Navigation component, where I did something like this:

const container = compose(withRouter, withDropdown);
const ButtonWithDropdown = container(Button);


function routeStateHasChanged(currentProps, prevProps) {
  return currentProps.location.state !== prevProps.location.state;
}

// ... then

  render() {
    <ButtonWithDropdown shouldHideOnUpdate={routeStateHasChanged}>
      {this.renderDropdown}
    </ButtonWithDropdown>
  }

2

Answers


  1. Is there any way of doing this without passing that .toggle() method down the Button > Dropdown > List > Notification > Link subtree?

    In the question, you mention that you are using redux.So I assume that you store showOnInit in redux.We don’t usually store a function in redux.In toggle function,I think you should dispatch an CHANGE_SHOW action to change the showOnInit in redux, then pass the show data not the function to the children component.Then after reducer dispatch,the react will change “show” automatically.

    switch (action.type) {
        case CHANGE_SHOW:
          return Object.assign({}, state, {
            showOnInit: action.text
          })
        ...
        default:
          return state
      }
    

    Link element and data pass

    Use the property in Link-to,not data-…Like this:

     <Link
      to={{
         pathname: url,
         state:{dismiss:"dropdown"}
         }}
    />
    

    And the state property will be found in this.props.location.

    give context a little try(not recommend)

    It may lead your project to instable and some other problems.(https://reactjs.org/docs/context.html#classcontexttype)

    First,define context

    const MyContext = React.createContext(defaultValue);
    

    Second,define pass value

    <MyContext.Provider value={this.toggle}>
    

    Then,get the value in the nested component

    <div value={this.context} />
    
    Login or Signup to reply.
  2. It seems you could simply make use of withRouter HOC and check if this.props.location.pathname has changed when componentDidUpdate:

    export default function withDropDown(ClickableElement) {
      class ClickableDropdown extends Component {
        // ...
    
        componentDidUpdate(prevProps) {
          if (this.props.location.pathname !== prevProps.location.pathname) {
            this.toggle(false);
          }
        }
    
        // ...
      };
    
      return withRouter(ClickableDropdown)
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search