diff --git a/docs/API.md b/docs/API.md index 5f85df59b8..a8221f436b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -4,6 +4,7 @@ - [``](#router) - [``](#link) - [``](#indexlink) + - [`withRouter`](#withRouter-component) - [``](#routercontext) - [`context.router`](#contextrouter) - `` (deprecated, use ``) @@ -162,6 +163,9 @@ Given a route like ``: ### `` An `` is like a [``](#link), except it is only active when the current route is exactly the linked route. It is equivalent to `` with the `onlyActiveOnIndex` prop set. +### `withRouter(component)` +A HoC (higher-order component) that wraps another component to provide `this.props.router`. Pass in your component and it will return the wrapped component. + ### `` A `` renders the component tree for a given router state. Its used by `` but also useful for server rendering and integrating in brownfield development. @@ -171,61 +175,6 @@ It also provides a `router` object on [context](https://facebook.github.io/react Contains data and methods relevant to routing. Most useful for imperatively transitioning around the application. -To use it, you must signal to React that you need it by declaring your use of it in your component via `contextTypes`: - -```js -var MyComponent = React.createClass({ - contextTypes: { - router: routerShape.isRequired - }, - - render: function() { - // Here, you can use this.context.router. - } -}) -``` - -To use `context.router` on a component declared as an ES2015 class, define `contextTypes` as a static property of the class: - -```js -class MyComponent extends React.Component { - render() { - // Here, you can use this.context.router. - } -} - -MyComponent.contextTypes = { - router: routerShape.isRequired -} -``` - -If you are using the class properties proposal, you can instead write: - -```js -class MyComponent extends React.Component { - static contextTypes = { - router: routerShape.isRequired - } - - render() { - // Here, you can use this.context.router. - } -} -``` - -To use `context.router` with -[stateless function components](https://facebook.github.io/react/docs/reusable-components.html#stateless-functions), declare `contextTypes` as a static property of the component function: - -```js -function MyComponent(props, context) { - // Here, you can use context.router. -} - -MyComponent.contextTypes = { - router: routerShape.isRequired -} -``` - ##### `push(pathOrLoc)` Transitions to a new URL, adding a new entry in the browser history. diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 016c17fc8f..231c6670aa 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -1,16 +1,17 @@ # Troubleshooting -### `this.context.router` is `undefined` +### How do I add `this.props.router` to my component? -You need to add `router` to your component's `contextTypes` to make the router object available to you. +You need to wrap your component using `withRouter` to make the router object available to you. ```js -contextTypes: { - router: routerShape.isRequired -} +const Component = withRouter( + React.createClass({ + //... + }) +) ``` - ### Getting the previous location ```js diff --git a/docs/guides/ConfirmingNavigation.md b/docs/guides/ConfirmingNavigation.md index 5561696024..bedcb6fc41 100644 --- a/docs/guides/ConfirmingNavigation.md +++ b/docs/guides/ConfirmingNavigation.md @@ -3,24 +3,22 @@ You can prevent a transition from happening or prompt the user before leaving a [route](/docs/Glossary.md#route) with a leave hook. ```js -const Home = React.createClass({ +const Home = withRouter( + React.createClass({ - contextTypes: { - router: routerShape.isRequired - }, + componentDidMount() { + this.props.router.setRouteLeaveHook(this.props.route, this.routerWillLeave) + }, - componentDidMount() { - this.context.router.setRouteLeaveHook(this.props.route, this.routerWillLeave) - }, + routerWillLeave(nextLocation) { + // return false to prevent a transition w/o prompting the user, + // or return a string to allow the user to decide: + if (!this.state.isSaved) + return 'Your work is not saved! Are you sure you want to leave?' + }, - routerWillLeave(nextLocation) { - // return false to prevent a transition w/o prompting the user, - // or return a string to allow the user to decide: - if (!this.state.isSaved) - return 'Your work is not saved! Are you sure you want to leave?' - }, + // ... - // ... - -}) + }) +) ``` diff --git a/docs/guides/NavigatingOutsideOfComponents.md b/docs/guides/NavigatingOutsideOfComponents.md index c29ba056f4..a2233b3f7e 100644 --- a/docs/guides/NavigatingOutsideOfComponents.md +++ b/docs/guides/NavigatingOutsideOfComponents.md @@ -1,6 +1,6 @@ # Navigating Outside of Components -While you can use `this.context.router` to navigate around, many apps want to be able to navigate outside of their components. They can do that with the history the app gives to `Router`. +While you can use `this.props.router` from `withRouter` to navigate around, many apps want to be able to navigate outside of their components. They can do that with the history the app gives to `Router`. ```js // your main file that renders a Router diff --git a/examples/auth-flow-async-with-query-params/app.js b/examples/auth-flow-async-with-query-params/app.js index 4877bcab7d..3475f2ea1e 100644 --- a/examples/auth-flow-async-with-query-params/app.js +++ b/examples/auth-flow-async-with-query-params/app.js @@ -1,7 +1,7 @@ import React, { createClass } from 'react' import { render } from 'react-dom' import { - Router, Route, IndexRoute, browserHistory, Link, routerShape + Router, Route, IndexRoute, browserHistory, Link, withRouter } from 'react-router' function App(props) { @@ -12,43 +12,42 @@ function App(props) { ) } -const Form = createClass({ - contextTypes: { - router: routerShape.isRequired - }, +const Form = withRouter( + createClass({ - getInitialState() { - return { - value: '' - } - }, - - submitAction(event) { - event.preventDefault() - this.context.router.push({ - pathname: '/page', - query: { - qsparam: this.state.value + getInitialState() { + return { + value: '' } - }) - }, + }, - handleChange(event) { - this.setState({ value: event.target.value }) - }, + submitAction(event) { + event.preventDefault() + this.props.router.push({ + pathname: '/page', + query: { + qsparam: this.state.value + } + }) + }, - render() { - return ( -
-

Token is pancakes

- - -

Or authenticate via URL

-

Or try failing to authenticate via URL

-
- ) - } -}) + handleChange(event) { + this.setState({ value: event.target.value }) + }, + + render() { + return ( +
+

Token is pancakes

+ + +

Or authenticate via URL

+

Or try failing to authenticate via URL

+
+ ) + } + }) +) function Page() { return

Hey, I see you are authenticated. Welcome!

diff --git a/examples/auth-flow/app.js b/examples/auth-flow/app.js index 6df7a6c242..54f4ed3ad2 100644 --- a/examples/auth-flow/app.js +++ b/examples/auth-flow/app.js @@ -1,6 +1,6 @@ import React from 'react' import { render } from 'react-dom' -import { browserHistory, Router, Route, Link, routerShape } from 'react-router' +import { browserHistory, Router, Route, Link, withRouter } from 'react-router' import auth from './auth' const App = React.createClass({ @@ -55,51 +55,49 @@ const Dashboard = React.createClass({ } }) -const Login = React.createClass({ +const Login = withRouter( + React.createClass({ - contextTypes: { - router: routerShape.isRequired - }, - - getInitialState() { - return { - error: false - } - }, - - handleSubmit(event) { - event.preventDefault() - - const email = this.refs.email.value - const pass = this.refs.pass.value - - auth.login(email, pass, (loggedIn) => { - if (!loggedIn) - return this.setState({ error: true }) - - const { location } = this.props - - if (location.state && location.state.nextPathname) { - this.context.router.replace(location.state.nextPathname) - } else { - this.context.router.replace('/') + getInitialState() { + return { + error: false } - }) - }, - - render() { - return ( -
- - (hint: password1)
- - {this.state.error && ( -

Bad login information

- )} -
- ) - } -}) + }, + + handleSubmit(event) { + event.preventDefault() + + const email = this.refs.email.value + const pass = this.refs.pass.value + + auth.login(email, pass, (loggedIn) => { + if (!loggedIn) + return this.setState({ error: true }) + + const { location } = this.props + + if (location.state && location.state.nextPathname) { + this.props.router.replace(location.state.nextPathname) + } else { + this.props.router.replace('/') + } + }) + }, + + render() { + return ( +
+ + (hint: password1)
+ + {this.state.error && ( +

Bad login information

+ )} +
+ ) + } + }) +) const About = React.createClass({ render() { diff --git a/examples/auth-with-shared-root/components/Login.js b/examples/auth-with-shared-root/components/Login.js index 6500df5a89..6770900065 100644 --- a/examples/auth-with-shared-root/components/Login.js +++ b/examples/auth-with-shared-root/components/Login.js @@ -1,12 +1,8 @@ import React from 'react' +import { withRouter } from 'react-router' import auth from '../utils/auth.js' const Login = React.createClass({ - - contextTypes: { - router: React.PropTypes.object - }, - getInitialState() { return { error: false @@ -26,9 +22,9 @@ const Login = React.createClass({ const { location } = this.props if (location.state && location.state.nextPathname) { - this.context.router.replace(location.state.nextPathname) + this.props.router.replace(location.state.nextPathname) } else { - this.context.router.replace('/') + this.props.router.replace('/') } }) }, @@ -48,4 +44,4 @@ const Login = React.createClass({ }) -export default Login +export default withRouter(Login) diff --git a/examples/confirming-navigation/app.js b/examples/confirming-navigation/app.js index ad32a3e780..5edb688003 100644 --- a/examples/confirming-navigation/app.js +++ b/examples/confirming-navigation/app.js @@ -1,6 +1,6 @@ import React from 'react' import { render } from 'react-dom' -import { browserHistory, Router, Route, Link, routerShape } from 'react-router' +import { browserHistory, Router, Route, Link, withRouter } from 'react-router' const App = React.createClass({ render() { @@ -22,57 +22,56 @@ const Dashboard = React.createClass({ } }) -const Form = React.createClass({ - contextTypes: { - router: routerShape.isRequired - }, +const Form = withRouter( + React.createClass({ - componentWillMount() { - this.context.router.setRouteLeaveHook( - this.props.route, - this.routerWillLeave - ) - }, + componentWillMount() { + this.props.router.setRouteLeaveHook( + this.props.route, + this.routerWillLeave + ) + }, - getInitialState() { - return { - textValue: 'ohai' - } - }, + getInitialState() { + return { + textValue: 'ohai' + } + }, - routerWillLeave() { - if (this.state.textValue) - return 'You have unsaved information, are you sure you want to leave this page?' - }, + routerWillLeave() { + if (this.state.textValue) + return 'You have unsaved information, are you sure you want to leave this page?' + }, - handleChange(event) { - this.setState({ - textValue: event.target.value - }) - }, + handleChange(event) { + this.setState({ + textValue: event.target.value + }) + }, - handleSubmit(event) { - event.preventDefault() + handleSubmit(event) { + event.preventDefault() - this.setState({ - textValue: '' - }, () => { - this.context.router.push('/') - }) - }, + this.setState({ + textValue: '' + }, () => { + this.props.router.push('/') + }) + }, - render() { - return ( -
-
-

Click the dashboard link with text in the input.

- - -
-
- ) - } -}) + render() { + return ( +
+
+

Click the dashboard link with text in the input.

+ + +
+
+ ) + } + }) +) render(( diff --git a/examples/master-detail/app.js b/examples/master-detail/app.js index 6123378314..8b445f5476 100644 --- a/examples/master-detail/app.js +++ b/examples/master-detail/app.js @@ -1,7 +1,7 @@ import React from 'react' import { render, findDOMNode } from 'react-dom' import { - browserHistory, Router, Route, IndexRoute, Link, routerShape + browserHistory, Router, Route, IndexRoute, Link, withRouter } from 'react-router' import ContactStore from './ContactStore' import './app.css' @@ -63,93 +63,91 @@ const Index = React.createClass({ } }) -const Contact = React.createClass({ - contextTypes: { - router: routerShape.isRequired - }, - - getStateFromStore(props) { - const { id } = props ? props.params : this.props.params - - return { - contact: ContactStore.getContact(id) - } - }, - - getInitialState() { - return this.getStateFromStore() - }, +const Contact = withRouter( + React.createClass({ - componentDidMount() { - ContactStore.addChangeListener(this.updateContact) - }, + getStateFromStore(props) { + const { id } = props ? props.params : this.props.params - componentWillUnmount() { - ContactStore.removeChangeListener(this.updateContact) - }, + return { + contact: ContactStore.getContact(id) + } + }, - componentWillReceiveProps(nextProps) { - this.setState(this.getStateFromStore(nextProps)) - }, + getInitialState() { + return this.getStateFromStore() + }, - updateContact() { - if (!this.isMounted()) - return - - this.setState(this.getStateFromStore()) - }, + componentDidMount() { + ContactStore.addChangeListener(this.updateContact) + }, - destroy() { - const { id } = this.props.params - ContactStore.removeContact(id) - this.context.router.push('/') - }, + componentWillUnmount() { + ContactStore.removeChangeListener(this.updateContact) + }, - render() { - const contact = this.state.contact || {} - const name = contact.first + ' ' + contact.last - const avatar = contact.avatar || 'http://placecage.com/50/50' + componentWillReceiveProps(nextProps) { + this.setState(this.getStateFromStore(nextProps)) + }, - return ( -
- -

{name}

- -
- ) - } -}) + updateContact() { + if (!this.isMounted()) + return -const NewContact = React.createClass({ - contextTypes: { - router: routerShape.isRequired - }, + this.setState(this.getStateFromStore()) + }, - createContact(event) { - event.preventDefault() + destroy() { + const { id } = this.props.params + ContactStore.removeContact(id) + this.props.router.push('/') + }, - ContactStore.addContact({ - first: findDOMNode(this.refs.first).value, - last: findDOMNode(this.refs.last).value - }, (contact) => { - this.context.router.push(`/contact/${contact.id}`) - }) - }, + render() { + const contact = this.state.contact || {} + const name = contact.first + ' ' + contact.last + const avatar = contact.avatar || 'http://placecage.com/50/50' - render() { - return ( -
-

- - -

-

- Cancel -

-
- ) - } -}) + return ( +
+ +

{name}

+ +
+ ) + } + }) +) + +const NewContact = withRouter( + React.createClass({ + + createContact(event) { + event.preventDefault() + + ContactStore.addContact({ + first: findDOMNode(this.refs.first).value, + last: findDOMNode(this.refs.last).value + }, (contact) => { + this.props.router.push(`/contact/${contact.id}`) + }) + }, + + render() { + return ( +
+

+ + +

+

+ Cancel +

+
+ ) + } + }) +) const NotFound = React.createClass({ render() { diff --git a/examples/passing-props-to-children/app.js b/examples/passing-props-to-children/app.js index ba98d42501..eef4842de5 100644 --- a/examples/passing-props-to-children/app.js +++ b/examples/passing-props-to-children/app.js @@ -1,64 +1,63 @@ import React from 'react' import { render } from 'react-dom' -import { browserHistory, Router, Route, Link, routerShape } from 'react-router' +import { browserHistory, Router, Route, Link, withRouter } from 'react-router' import './app.css' -const App = React.createClass({ - contextTypes: { - router: routerShape.isRequired - }, +const App = withRouter( + React.createClass({ - getInitialState() { - return { - tacos: [ - { name: 'duck confit' }, - { name: 'carne asada' }, - { name: 'shrimp' } - ] - } - }, + getInitialState() { + return { + tacos: [ + { name: 'duck confit' }, + { name: 'carne asada' }, + { name: 'shrimp' } + ] + } + }, - addTaco() { - let name = prompt('taco name?') + addTaco() { + let name = prompt('taco name?') - this.setState({ - tacos: this.state.tacos.concat({ name }) - }) - }, + this.setState({ + tacos: this.state.tacos.concat({ name }) + }) + }, - handleRemoveTaco(removedTaco) { - this.setState({ - tacos: this.state.tacos.filter(function (taco) { - return taco.name != removedTaco + handleRemoveTaco(removedTaco) { + this.setState({ + tacos: this.state.tacos.filter(function (taco) { + return taco.name != removedTaco + }) }) - }) - this.context.router.push('/') - }, + this.props.router.push('/') + }, - render() { - let links = this.state.tacos.map(function (taco, i) { + render() { + let links = this.state.tacos.map(function (taco, i) { + return ( +
  • + {taco.name} +
  • + ) + }) return ( -
  • - {taco.name} -
  • - ) - }) - return ( -
    - -
      - {links} -
    -
    - {this.props.children && React.cloneElement(this.props.children, { - onRemoveTaco: this.handleRemoveTaco - })} +
    + +
      + {links} +
    +
    + {this.props.children && React.cloneElement(this.props.children, { + onRemoveTaco: this.handleRemoveTaco + })} +
    -
    - ) - } -}) + ) + } + }) +) const Taco = React.createClass({ remove() { diff --git a/modules/__tests__/withRouter-test.js b/modules/__tests__/withRouter-test.js new file mode 100644 index 0000000000..ebfdc58fbd --- /dev/null +++ b/modules/__tests__/withRouter-test.js @@ -0,0 +1,41 @@ +import expect from 'expect' +import React, { Component } from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import createHistory from '../createMemoryHistory' +import Route from '../Route' +import Router from '../Router' +import routerShape from '../PropTypes' +import withRouter from '../withRouter' + +describe('withRouter', function () { + class App extends Component { + propTypes: { + router: routerShape.isRequired + } + render() { + expect(this.props.router).toExist() + return

    App

    + } + } + + let node + beforeEach(function () { + node = document.createElement('div') + }) + + afterEach(function () { + unmountComponentAtNode(node) + }) + + it('puts router on context', function (done) { + const WrappedApp = withRouter(App) + + render(( + + + + ), node, function () { + done() + }) + }) +}) diff --git a/modules/index.js b/modules/index.js index 56761bad18..4317161557 100644 --- a/modules/index.js +++ b/modules/index.js @@ -2,6 +2,7 @@ export Router from './Router' export Link from './Link' export IndexLink from './IndexLink' +export withRouter from './withRouter' /* components (configuration) */ export IndexRedirect from './IndexRedirect' diff --git a/modules/withRouter.js b/modules/withRouter.js new file mode 100644 index 0000000000..e9e7f32fda --- /dev/null +++ b/modules/withRouter.js @@ -0,0 +1,21 @@ +import React from 'react' +import hoistStatics from 'hoist-non-react-statics' +import { routerShape } from './PropTypes' + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component' +} + +export default function withRouter(WrappedComponent) { + const WithRouter = React.createClass({ + contextTypes: { router: routerShape }, + render() { + return + } + }) + + WithRouter.displayName = `withRouter(${getDisplayName(WrappedComponent)})` + WithRouter.WrappedComponent = WrappedComponent + + return hoistStatics(WithRouter, WrappedComponent) +} diff --git a/package.json b/package.json index 43dcf1cc14..40bc492a1d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "license": "MIT", "dependencies": { "history": "^2.0.1", + "hoist-non-react-statics": "^1.0.5", "invariant": "^2.2.1", "warning": "^2.1.0" }, diff --git a/upgrade-guides/v2.0.0.md b/upgrade-guides/v2.0.0.md index b93bc7193e..cf7786b6d3 100644 --- a/upgrade-guides/v2.0.0.md +++ b/upgrade-guides/v2.0.0.md @@ -102,7 +102,9 @@ const appHistory = useScroll(useRouterHistory(createBrowserHistory))(); ## Changes to `this.context` -Only an object named `router` is added to context. Accessing `this.context.history`, `this.context.location`, and `this.context.route` are all deprecated. This new object contains the methods available from `history` (such as `push`, `replace`) along with `setRouteLeaveHook`. +**NOTE: v2.4.0 and higher include a higher-order component `withRouter` that is now the (highly) recommended way of accessing the `router` object.** Read [the v2.4.0 upgrade guide](/upgrade-guides/v2.4.0.md) for more details. + +Only an object named `router` is added to context. Accessing `this.context.history`, `this.context.location`, and `this.context.route` are all deprecated. This new object contains the methods available from `history` (such as `push`, `replace`) along with `setRouteLeaveHook` and `isActive`. ### Accessing location diff --git a/upgrade-guides/v2.4.0.md b/upgrade-guides/v2.4.0.md new file mode 100644 index 0000000000..14a7791180 --- /dev/null +++ b/upgrade-guides/v2.4.0.md @@ -0,0 +1,31 @@ +# v2.4.0 Upgrade Guide + +## `withRouter` HoC (higher-order component) + +Prior to 2.4.0, you could access the `router` object via [`this.context`](https://facebook.github.io/react/docs/context.html). This is still true, but `context` is often times a difficult and error-prone API to work with. + +In order to more easily access the `router` object, a `withRouter` higher-order component has been added as the new primary means of access. As with other HoCs, it is usable on any React Component of any type (`React.createClass`, ES2015 `React.Component` classes, stateless functional components). + +```js +import React from 'react' +import { withRouter } from 'react-router' + +const Page = React.createClass({ + + componentDidMount() { + this.props.router.setRouteLeaveHook(this.props.route, () => { + if (this.state.unsaved) + return 'You have unsaved information, are you sure you want to leave this page?' + }) + } + + render() { + return
    Stuff
    + } + +}) + +export default withRouter(Login) +``` + +**It's important to note this is not a deprecation of the `context` API.** As long as React supports `this.context` in its current form, any code written for that API will continue to work. We will continue to use it internally and you can continue to write in that format, if you want. We think this new HoC is nicer and easier, and will be using it in documentation and examples, but it is not a hard requirement to switch.