Dependency Injection
with
React Context
Encapsulation
<Alert />
<Button />
<Feed />
we can take a component like this and use it anywhere we want
withouth needing to know anything about its implementation details.
makes it easier to read & reason about our code, because all the
details that we don't need to worry about right now are hidden away
Customisation
- Change the colour or text
- Change the onClick behaviour
- Change data fetching or state management
- Change behaviour of child component
- Change logging/analytics
- Replace services for testing
we can take a component like this and use it anywhere we want
withouth needing to know anything about its implementation details.
makes it easier to read & reason about our code, because all the
details that we don't need to worry about right now are hidden away
<Alert status="error">Something went wrong</Alert>
<Button type="primary" onClick={...}>Save</Button>
<Feed
theme="dark"
posts={fetchPublicPosts()}
onLike={showLoginModal}
onComment={showLoginModal}
analyticsService={analyticsService}
/>
Dependency injection means
giving
an object a component
its
instance local
variables.
- James Shore,
Dependency Injection Demystified
and that's basically all dependency injection is
doesn't quite work with react, because it's referring to instance
variables of objects, and react works with functions, change to
"giving a component its variables" emphasise a variable could be a
value or it could be a function
a 25-dollar term for a
5-cent concept
So why do we need a fancy name like dependency injection if its just
props?
Not all props are dependencies
<Alert status="error">Something went wrong</Alert>🙅
<Button type="primary" onClick={...}>Save</Button>🤷
<Feed
theme="dark"
posts={fetchPublicPosts()}
onLike={showLoginModal}
onComment={showLoginModal}
analyticsService={analyticsService}
/>🎉
Not all dependency injection is props
| function Feed() { |
| const { theme, posts, onLike, |
| onComment, analyticsService } = Feed.dependencies; |
| |
| |
| } |
| |
| Feed.dependencies = { |
| theme: getTheme(), |
| posts: fetchPublicPosts(), |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| } |
| function Feed() { |
| const { theme, posts, onLike, |
| onComment, analyticsService } = Feed.dependencies; |
| |
| |
| } |
| |
| Feed.dependencies = { |
| theme: getTheme(), |
| posts: fetchPublicPosts(), |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| } |
| function Feed() { |
| const { theme, posts, onLike, |
| onComment, analyticsService } = Feed.dependencies; |
| |
| |
| } |
| |
| Feed.dependencies = { |
| theme: getTheme(), |
| posts: fetchPublicPosts(), |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| } |
why would you want to do something like this?
| function Feed({ theme, posts, onLike, |
| onComment, analyticsService }) { |
| |
| return ( |
| <FeedContainer theme={theme}> |
| {posts.map(post => ( |
| <Post |
| post={post} theme={theme} |
| onLike={onLike} onComment={onComment} |
| analyticsService={analyticsService} |
| />))} |
| </FeedContainer> |
| ) |
| } |
| function Feed({ theme, posts, onLike, |
| onComment, analyticsService }) { |
| |
| return ( |
| <FeedContainer theme={theme}> |
| {posts.map(post => ( |
| <Post |
| post={post} theme={theme} |
| onLike={onLike} onComment={onComment} |
| analyticsService={analyticsService} |
| />))} |
| </FeedContainer> |
| ) |
| } |
| function Feed({ theme, posts, onLike, |
| onComment, analyticsService }) { |
| |
| return ( |
| <FeedContainer theme={theme}> |
| {posts.map(post => ( |
| <Post |
| post={post} theme={theme} |
| onLike={onLike} onComment={onComment} |
| analyticsService={analyticsService} |
| />))} |
| </FeedContainer> |
| ) |
| } |
| function Feed({ theme, posts, onLike, |
| onComment, analyticsService }) { |
| |
| return ( |
| <FeedContainer theme={theme}> |
| {posts.map(post => ( |
| <Post |
| post={post} theme={theme} |
| onLike={onLike} onComment={onComment} |
| analyticsService={analyticsService} |
| />))} |
| </FeedContainer> |
| ) |
| } |
| function Post({ theme, onLike, onComment, |
| analyticsService, post }) { |
| |
| return ( |
| <PostContainer theme={theme}> |
| <Content theme={theme} post={post} /> |
| <LikeButton |
| theme={theme} onLike={onLike} |
| analyticsService={analyticsService} /> |
| <CommentButton |
| theme={theme} onLike={onLike} |
| analyticsService={analyticsService} /> |
| </PostContainer> |
| ) |
| } |
| function Post({ theme, onLike, onComment, |
| analyticsService, post }) { |
| |
| return ( |
| <PostContainer theme={theme}> |
| <Content theme={theme} post={post} /> |
| <LikeButton |
| theme={theme} onLike={onLike} |
| analyticsService={analyticsService} /> |
| <CommentButton |
| theme={theme} onLike={onLike} |
| analyticsService={analyticsService} /> |
| </PostContainer> |
| ) |
| } |
| function Post({ theme, onLike, onComment, |
| analyticsService, post }) { |
| |
| return ( |
| <PostContainer theme={theme}> |
| <Content theme={theme} post={post} /> |
| <LikeButton |
| theme={theme} onLike={onLike} |
| analyticsService={analyticsService} /> |
| <CommentButton |
| theme={theme} onLike={onLike} |
| analyticsService={analyticsService} /> |
| </PostContainer> |
| ) |
| } |
so we could use a solution like this, to avoid this prop drilling, but it still requires setting the
dependencies on each component individually, even if you can skip the "middleman" components
| function Post() { |
| |
| } |
| Post.dependencies = { theme: getTheme() } |
| |
| function LikeButton() { |
| |
| } |
| LikeButton.dependencies = { |
| theme: getTheme(), |
| analyticsService, |
| onLike: showLoginModal |
| } |
| function Post() { |
| |
| } |
| Post.dependencies = { theme: getTheme() } |
| |
| function LikeButton() { |
| |
| } |
| LikeButton.dependencies = { |
| theme: getTheme(), |
| analyticsService, |
| onLike: showLoginModal |
| } |
| function Post() { |
| |
| } |
| Post.dependencies = { theme: getTheme() } |
| |
| function LikeButton() { |
| |
| } |
| LikeButton.dependencies = { |
| theme: getTheme(), |
| analyticsService, |
| onLike: showLoginModal |
| } |
and react gives us a more reacty way to do this
so, in our ideal scenario, we could create a container that managed our dependencies, like so
| export function App() { |
| |
| |
| return ( |
| <DependenciesContainer |
| dependencies={{ |
| theme: getTheme(), posts: fetchPublicPost(), |
| onLike: showLoginModal, onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </DependenciesContainer> |
| ) |
| } |
| export function App() { |
| |
| |
| return ( |
| <DependenciesContainer |
| dependencies={{ |
| theme: getTheme(), posts: fetchPublicPost(), |
| onLike: showLoginModal, onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </DependenciesContainer> |
| ) |
| } |
| export function App() { |
| |
| |
| return ( |
| <DependenciesContainer |
| dependencies={{ |
| theme: getTheme(), posts: fetchPublicPost(), |
| onLike: showLoginModal, onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </DependenciesContainer> |
| ) |
| } |
| export function App() { |
| |
| |
| return ( |
| <DependenciesContainer |
| dependencies={{ |
| theme: getTheme(), posts: fetchPublicPost(), |
| onLike: showLoginModal, onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </DependenciesContainer> |
| ) |
| } |
and then we could use the dependencies in any of the child components something like
| function Feed() { |
| |
| const { |
| theme, |
| posts |
| } = useDependencies(); |
| |
| |
| } |
| function LikeButton() { |
| |
| const { |
| theme, |
| onLike, |
| analyticsService |
| } = useDependencies(); |
| |
| |
| } |
so how do we make this work?
First, we need a function that creates the container component and exposes its value
| function createDependencies() { |
| const dependencies = { value: undefined } |
| |
| return { |
| dependencies, |
| Container: ({ children, deps }) => { |
| dependencies.value = deps |
| return children |
| }, |
| } |
| } |
| function createDependencies() { |
| const dependencies = { value: undefined } |
| |
| return { |
| dependencies, |
| Container: ({ children, deps }) => { |
| dependencies.value = deps |
| return children |
| }, |
| } |
| } |
| function createDependencies() { |
| const dependencies = { value: undefined } |
| |
| return { |
| dependencies, |
| Container: ({ children, deps }) => { |
| dependencies.value = deps |
| return children |
| }, |
| } |
| } |
| function createDependencies() { |
| const dependencies = { value: undefined } |
| |
| return { |
| dependencies, |
| Container: ({ children, deps }) => { |
| dependencies.value = deps |
| return children |
| }, |
| } |
| } |
| function createDependencies() { |
| const dependencies = { value: undefined } |
| |
| return { |
| dependencies, |
| Container: ({ children, deps }) => { |
| dependencies.value = deps |
| return children |
| }, |
| } |
| } |
| function createDependencies() { |
| const dependencies = { value: undefined } |
| |
| return { |
| dependencies, |
| Container: ({ children, deps }) => { |
| dependencies.value = deps |
| return children |
| }, |
| } |
| } |
| function createDependencies() { |
| const dependencies = { value: undefined } |
| |
| return { |
| dependencies, |
| Container: ({ children, deps }) => { |
| dependencies.value = deps |
| return children |
| }, |
| } |
| } |
| function useDependencies(container) { |
| return container.dependencies.value |
| } |
| function useDependencies(container) { |
| return container.dependencies.value |
| } |
| function useDependencies(container) { |
| return container.dependencies.value |
| } |
then we can use our function to create our container
| const feedDependencies = createDependencies() |
| export function PublicFeed() { |
| return ( |
| <feedDependencies.Container |
| deps={{ |
| theme: getTheme(), |
| posts: fetchPublicPosts(), |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </myDependencies.Container> |
| ) |
| } |
| export function PublicFeed() { |
| return ( |
| <feedDependencies.Container |
| deps={{ |
| theme: getTheme(), |
| posts: fetchPublicPosts(), |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </myDependencies.Container> |
| ) |
| } |
| export function PublicFeed() { |
| return ( |
| <feedDependencies.Container |
| deps={{ |
| theme: getTheme(), |
| posts: fetchPublicPosts(), |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </myDependencies.Container> |
| ) |
| } |
| export function ChronologicalFeed() { |
| return ( |
| <feedDependencies.Container |
| deps={{ |
| theme: getTheme(), |
| posts: fetchUserPosts(), |
| onLike: postLike, |
| onComment: postComment, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </myDependencies.Container> |
| ) |
| } |
| export function ChronologicalFeed() { |
| return ( |
| <feedDependencies.Container |
| deps={{ |
| theme: getTheme(), |
| posts: fetchUserPosts(), |
| onLike: postLike, |
| onComment: postComment, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </myDependencies.Container> |
| ) |
| } |
and use the hook in our child components
| export function LikeButton() { |
| const { |
| theme, |
| onLike, |
| analyticsService |
| } = useDependencies(feedDependencies) |
| |
| |
| } |
squint a little and maybe change some of the variable names, some of this might look a little familiar
createDependencies();
createContext();
const feedDependencies =
feedDependencies.Container
feedDependencies.Provider
feedDependencies.Container>
feedDependencies.Provider>
| export function PublicFeed() { |
| return ( |
| < |
| value={{ |
| theme: getTheme(), |
| posts: fetchPublicPosts(), |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| }} |
| > |
| <Feed /> |
| </ |
| ) |
| } |
useDependencies(feedDependencies)
useContext(feedDependencies)
| export function LikeButton() { |
| const { |
| theme, |
| onLike, |
| analyticsService |
| } = |
| |
| |
| } |
React Context
is
Dependency Injection
so that's nice, it's built in, don't need to learn anything, but it's not very discoverable
React Context
- Built in 👍
- Discoverability 👎
it('foo', () => {
render(<Feed />)
})
Obsidian
@Singleton() @Graph()
class ApplicationGraph extends ObjectGraph {
@Provides()
httpClient(): HttpClient {
return new HttpClient();
}
@Provides()
biLogger(httpClient: HttpClient): BiLogger {
return new BiLogger(httpClient);
}
}
[docs]
- convert all imports to ES6 classes
- make dependencies configurable by making more dependencies
Obsidian
| const useButtonClick = ({ biLogger }: Injected): UseButtonPress => { |
| const onClick = useCallback(() => { |
| biLogger.logButtonClick(); |
| }, [biLogger]); |
| |
| return { onClick }; |
| }; |
| |
| export const useButton = injectHook( |
| useButtonClick, |
| ApplicationGraph |
| ); |
| const useButtonClick = ({ biLogger }: Injected): UseButtonPress => { |
| const onClick = useCallback(() => { |
| biLogger.logButtonClick(); |
| }, [biLogger]); |
| |
| return { onClick }; |
| }; |
| |
| export const useButton = injectHook( |
| useButtonClick, |
| ApplicationGraph |
| ); |
| const useButtonClick = ({ biLogger }: Injected): UseButtonPress => { |
| const onClick = useCallback(() => { |
| biLogger.logButtonClick(); |
| }, [biLogger]); |
| |
| return { onClick }; |
| }; |
| |
| export const useButton = injectHook( |
| useButtonClick, |
| ApplicationGraph |
| ); |
dependencies resolved at compile time
This approach helps reduce the amount of boilerplate code required by developers
- The Obsidian Docs
- solves the discoverability problem 👍
- difficult to customise 👎
- test via Jest mocks 😑
| @Singleton() @Graph() |
| class Feed extends ObjectGraph { |
| @Provides() |
| publicPosts(): Post[] { |
| return new PublicPosts(); |
| } |
| |
| @Provides() |
| userPosts(): Post[] { |
| return new BiLogger(httpClient); |
| } |
| |
| @Provides() |
| appConfig(): AppConfig { |
| return new AppConfig(); |
| } |
| |
| @Provides() |
| posts(): Post[] { |
| return appConfig.algorithm === user ? |
| publicPosts() : |
| userPosts() |
| } |
| } |
| @Singleton() @Graph() |
| class Feed extends ObjectGraph { |
| @Provides() |
| publicPosts(): Post[] { |
| return new PublicPosts(); |
| } |
| |
| @Provides() |
| userPosts(): Post[] { |
| return new BiLogger(httpClient); |
| } |
| |
| @Provides() |
| appConfig(): AppConfig { |
| return new AppConfig(); |
| } |
| |
| @Provides() |
| posts(): Post[] { |
| return appConfig.algorithm === user ? |
| publicPosts() : |
| userPosts() |
| } |
| } |
| @Singleton() @Graph() |
| class Feed extends ObjectGraph { |
| @Provides() |
| publicPosts(): Post[] { |
| return new PublicPosts(); |
| } |
| |
| @Provides() |
| userPosts(): Post[] { |
| return new BiLogger(httpClient); |
| } |
| |
| @Provides() |
| appConfig(): AppConfig { |
| return new AppConfig(); |
| } |
| |
| @Provides() |
| posts(): Post[] { |
| return appConfig.algorithm === user ? |
| publicPosts() : |
| userPosts() |
| } |
| } |
| @Singleton() @Graph() |
| class Feed extends ObjectGraph { |
| @Provides() |
| publicPosts(): Post[] { |
| return new PublicPosts(); |
| } |
| |
| @Provides() |
| userPosts(): Post[] { |
| return new BiLogger(httpClient); |
| } |
| |
| @Provides() |
| appConfig(): AppConfig { |
| return new AppConfig(); |
| } |
| |
| @Provides() |
| posts(): Post[] { |
| return appConfig.algorithm === user ? |
| publicPosts() : |
| userPosts() |
| } |
| } |
- solves the discoverability problem 👍
- difficult to customise 👎
- test via Jest mocks 😑
wox-inject
| @Injectable() |
| class GreeterService { |
| greet(val: string) { |
| console.log(val); |
| } |
| } |
| |
| function App() { |
| const greeterService = useResolve(GreeterService); |
| |
| return ( |
| <button |
| type="button" |
| onClick={ |
| () => greeterService.greet('hello!') |
| } |
| > |
| click me |
| </button> |
| ); |
| } |
| @Injectable() |
| class GreeterService { |
| greet(val: string) { |
| console.log(val); |
| } |
| } |
| |
| function App() { |
| const greeterService = useResolve(GreeterService); |
| |
| return ( |
| <button |
| type="button" |
| onClick={ |
| () => greeterService.greet('hello!') |
| } |
| > |
| click me |
| </button> |
| ); |
| } |
| @Injectable() |
| class GreeterService { |
| greet(val: string) { |
| console.log(val); |
| } |
| } |
| |
| function App() { |
| const greeterService = useResolve(GreeterService); |
| |
| return ( |
| <button |
| type="button" |
| onClick={ |
| () => greeterService.greet('hello!') |
| } |
| > |
| click me |
| </button> |
| ); |
| } |
[docs]
similar to obsidian with the annotations, only works with vite, no SSR
- doesn't solve discoverability 👎
- customisation? 🤷
- nice testbed 👍
- requires other libraries
- not quite prod-ready? 🤷
| @Injectable() |
| class Posts { |
| onLike(postId: string) { |
| return doLike(postId); |
| } |
| } |
| |
| function LikeButton() { |
| const handleLike = useResolve(Posts); |
| |
| return ( |
| <button onClick={handleLike}>🩶</button> |
| ); |
| } |
- doesn't solve discoverability 👎
- customisation? 🤷
- nice testbed 👍
- requires other libraries 😑
- not quite prod-ready? 🤷
| const feedDependencies = createContext({ |
| theme: getTheme(), |
| getPosts: fetchPublicPosts, |
| onLike: showLoginModal, |
| onComment: showLoginModal, |
| analyticsService |
| }); |
❌
const feedDependencies = createContext(undefined)
| function LikeButton() { |
| |
| const dependencies = useContext(feedDependencies); |
| if (!dependencies) { |
| throw new Error( |
| 'LikeButton requires DependenciesContext' |
| ) |
| } |
| const { |
| theme, |
| onLike, |
| analyticsService |
| } = dependencies; |
| |
| |
| } |
What about dependencies that aren't exposed?
| function LoginForm() { |
| const [username, setUsername] = useState("") |
| const [password, setPassword] = useState("") |
| |
| const onSubmit = () => { |
| if (username && password) { |
| login(username, password) |
| } |
| } |
| |
| return ( |
| <form onSubmit={onSubmit}> |
| <input value={username} onChange={setUsername} /> |
| <input type="password" value={password} onChange={setPassword} /> |
| </form> |
| ) |
| } |
| function LoginForm() { |
| const [username, setUsername] = useState("") |
| const [password, setPassword] = useState("") |
| |
| const onSubmit = () => { |
| if (username && password) { |
| login(username, password) |
| } |
| } |
| |
| return ( |
| <form onSubmit={onSubmit}> |
| <input value={username} onChange={setUsername} /> |
| <input type="password" value={password} onChange={setPassword} /> |
| </form> |
| ) |
| } |
it("calls the login service", () => {
render(<LoginForm />)
expect(login).toHaveBeenCalledWith(username, password)
})
react-magnetic-di
one library i do find super useful though, specifically for
injecting dependencies in tests no specific set-up, code doesn't
even know that its dependencies are being injected can replace
basically anything that's imported examples
| import { |
| injectable, DiProvider |
| } from "react-magnetic-di"; |
| import { login } from './services' |
| |
| it("calls the login service", () => { |
| const loginDi = injectable(login, jest.fn()) |
| render(<LoginForm />, { |
| wrapper: (props) => |
| <DiProvider use={[loginDi]} {...props} />, |
| }) |
| |
| |
| |
| expect(loginDi).toHaveBeenCalledWith(username, password) |
| }) |
| import { |
| injectable, DiProvider |
| } from "react-magnetic-di"; |
| import { login } from './services' |
| |
| it("calls the login service", () => { |
| const loginDi = injectable(login, jest.fn()) |
| render(<LoginForm />, { |
| wrapper: (props) => |
| <DiProvider use={[loginDi]} {...props} />, |
| }) |
| |
| |
| |
| expect(loginDi).toHaveBeenCalledWith(username, password) |
| }) |
| import { |
| injectable, DiProvider |
| } from "react-magnetic-di"; |
| import { login } from './services' |
| |
| it("calls the login service", () => { |
| const loginDi = injectable(login, jest.fn()) |
| render(<LoginForm />, { |
| wrapper: (props) => |
| <DiProvider use={[loginDi]} {...props} />, |
| }) |
| |
| |
| |
| expect(loginDi).toHaveBeenCalledWith(username, password) |
| }) |
| import { |
| injectable, DiProvider |
| } from "react-magnetic-di"; |
| import { login } from './services' |
| |
| it("calls the login service", () => { |
| const loginDi = injectable(login, jest.fn()) |
| render(<LoginForm />, { |
| wrapper: (props) => |
| <DiProvider use={[loginDi]} {...props} />, |
| }) |
| |
| |
| |
| expect(loginDi).toHaveBeenCalledWith(username, password) |
| }) |
| import { |
| injectable, DiProvider |
| } from "react-magnetic-di"; |
| import { login } from './services' |
| |
| it("calls the login service", () => { |
| const loginDi = injectable(login, jest.fn()) |
| render(<LoginForm />, { |
| wrapper: (props) => |
| <DiProvider use={[loginDi]} {...props} />, |
| }) |
| |
| |
| |
| expect(loginDi).toHaveBeenCalledWith(username, password) |
| }) |
| import { |
| injectable, DiProvider |
| } from "react-magnetic-di"; |
| import { login } from './services' |
| |
| it("calls the login service", () => { |
| const loginDi = injectable(login, jest.fn()) |
| render(<LoginForm />, { |
| wrapper: (props) => |
| <DiProvider use={[loginDi]} {...props} />, |
| }) |
| |
| |
| |
| expect(loginDi).toHaveBeenCalledWith(username, password) |
| }) |
| function LoginForm() { |
| |
| const onSubmit = () => { |
| if (username && password) { |
| login(username, password) |
| } |
| } |
| |
| } |
| function LoginForm() { |
| |
| const onSubmit = () => { |
| if (username && password) { |
| login(username, password) |
| } |
| } |
| |
| } |
| function LoginForm() { |
| |
| const onSubmit = () => { |
| if (username && password) { |
| const loginDi = _di(login) |
| loginDi(username, password) |
| } |
| } |
| |
| } |
const loginDi = injectable(login, jest.fn())
const loginDi = _di(login)
loginDi(username, password)
- Build time (Babel)
- Can replace any export
- Best with classes & functions
- No library-specific code
can be done without babel, but is a bit trickier
if you want to inject a value, turn it into a getter function
Summary
- Customisation + Encapsulation
- React context == DI
createContext(undefined)
- react-magnetic-di