See the bottom of this answer to read a direct response to the question's content. I'll start with good practices we use in our everyday development.
Redux offers a useful compose
utility function.
All compose
does is let you write deeply nested function transformations without the rightward drift of the code.
So here, we can use it to nest HoCs but in a readable way.
// Returns a new HoC (function taking a component as a parameter)
export default compose(
// Parent HoC feeds the Auth HoC
connect(({ user: { token, triedLoadFromStorage: tried } }) => ({
token,
tried
})),
// Your own HoC
AuthenticationHOC
);
Which would be similar to manually creating a new container HoC function.
const mapState = ({ user: { token, triedLoadFromStorage: tried } }) => ({
token,
tried
});
const withAuth = (WrappedComponent) => connect(mapState)(
AuthenticationHOC(WrappedComponent)
);
export default withAuth;
Then, you can use your auth HoC transparently.
import withAuth from '../AuthenticationHOC';
// ...
export default withAuth(ComponentNeedingAuth);
Writing a clean and testable HoC
In order to isolate the auth component from the store and the routing, we could split it in multiple files, each with its own responsibility.
- withAuth/
- index.js // Wiring and exporting (container component)
- withAuth.jsx // Defining the presentational logic
- withAuth.test.jsx // Testing the logic
We keep the withAuth.jsx
file focused on the rendering and the logic, regardless from where it's coming.
// withAuth/withAuth.jsx
import React from 'react';
const withAuth = (WrappedComponent) => ({
// Destructure props here, which filters them at the same time.
tried,
token,
getDashboardFromStorage,
getUserFromStorage,
onUnauthenticated,
...props
}) => {
// if user is not logged and we 've not checked the localStorage
if (!token && !tried) {
// try load the data from local storage
getDashboardFromStorage();
getUserFromStorage();
} else {
// if the user has no token or we tried to load from localStorage
onUnauthenticated();
}
// if the user has token render the component PASSING DOWN the props.
return <WrappedComponent {...props} />;
};
export default withAuth;
See? Our HoC is now unaware of the store and routing logic. We could move the redirection into a store middleware, or anywhere else, it could even be customized in a prop <Component onUnauthenticated={() => console.log('No token!')} />
if the store is not the place you'd like it.
Then, we only provide the props in the index.js
, like a container component.1
// withAuth/index.js
import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { getDashboardFromStorage, onUnauthenticated } from '../actions/user-actions';
import { getUserFromStorage } from '../actions/dashboard-actions';
import withAuth from './withAuth';
export default compose(
connect(({ user: { token, triedLoadFromStorage: tried } }) => ({
token,
tried
}), {
// provide only needed actions, then no `dispatch` prop is passed down.
getDashboardFromStorage,
getUserFromStorage,
// create a new action for the user so that your reducers can react to
// not being authenticated
onUnauthenticated,
}),
withAuth
);
The good thing about the onUnauthenticated
as a store action is that different reducers could now react to it, like wiping the user data, reset the dashboard data, etc.
Testing the HoC
Then, it's possible to test the isolated logic of the withAuth
HoC with something like Jest and enzyme.
// withAuth/withAuth.test.jsx
import React from 'react';
import { mount } from 'enzyme';
import withAuth from './withAuth';
describe('withAuth HoC', () => {
let WrappedComponent;
let onUnauthenticated;
beforeEach(() => {
WrappedComponent = jest.fn(() => null).mockName('WrappedComponent');
// mock the different functions to check if they were called or not.
onUnauthenticated = jest.fn().mockName('onUnauthenticated');
});
it('should call onUnauthenticated if blah blah', async () => {
const Component = withAuth(WrappedComponent);
await mount(
<Component
passThroughProp
onUnauthenticated={onUnauthenticated}
token={false}
tried
/>
);
expect(onUnauthenticated).toHaveBeenCalled();
// Make sure props on to the wrapped component are passed down
// to the original component, and that it is not polluted by the
// auth HoC's store props.
expect(WrappedComponent).toHaveBeenLastCalledWith({
passThroughProp: true
}, {});
});
});
Add more tests for different logical paths.
About your situation
So I suspect that connect
returns an object that can't be used as a HoC function.
react-redux's connect
returns an HoC.
import { login, logout } from './actionCreators'
const mapState = state => state.user
const mapDispatch = { login, logout }
// first call: returns a hoc that you can use to wrap any component
const connectUser = connect(
mapState,
mapDispatch
)
// second call: returns the wrapper component with mergedProps
// you may use the hoc to enable different components to get the same behavior
const ConnectedUserLogin = connectUser(Login)
const ConnectedUserProfile = connectUser(Profile)
In most cases, the wrapper function will be called right away, without
being saved in a temporary variable:
export default connect(mapState, mapDispatch)(Login)
then I tried to use this like this
AuthenticationHOC(connect(mapStateToProps)(HomeComponent))
You were close, though the order in which you wired the HoCs is reversed. It should be:
connect(mapStateToProps)(AuthenticationHOC(HomeComponent))
This way, the AuthenticationHOC
receives the props from the store and HomeComponent
is correctly wrapped by the right HoC, which would return a new valid component.
That being said, there's a lot we could do to improve this HoC!
1. If you're unsure about using the index.js file for a container component, you can refactor this however you like, say a withAuthContainer.jsx file which is either exported in the index or it lets the developer choose the one they need.